目录
为什么「今天又干成一件整事」这句抱怨很难反驳
因为它一直只是个感觉。
你隐隐知道今天被打断了很多次,知道某次会议把下午劈成两半,知道自己在三个模块之间来回跳——但当有人问「那你为什么不把它集中做完」,你答不上来,你只有疲惫感做证据。反过来,写日报的时候「今天完成了 A 和 B」也常常是编出来的叙事,因为你自己都说不清那些时间去哪了。
要把这件事谈下去,得先有一份数据。好消息是:每个写代码的人电脑里都躺着一份现成的、免安装的行为日志——git 提交时间戳。它记录了你在什么时刻、以什么顺序、动了哪些文件,而它不依赖你回忆。
这篇文章做三件事:说清成本在哪(第一节),给一段脚本从你自己的仓库里把数据挖出来(第二、三节),最后诚实交代这个口径能测什么、在哪会骗你(第五、六节)。
一、成本在哪:两种时刻表
关于上下文切换最清楚的一篇短文,是 Paul Graham 2009 年的《Maker’s Schedule, Manager’s Schedule》。他把一天分成两种用法:
- 管理者时刻表:以小时为单位,开完会接下一个会,每次切换的代价小,因为「下一个小时总有事情在等着,问题只是哪一件」。
- 制造者时刻表:以半天为单位。写代码、写作、设计这类工作需要连续的整块时间才能进入状态。
他写下的那句判断,做过深度工作的人一看就认得:
One meeting can blow a whole afternoon, by breaking it into two pieces each too small to do anything hard in.
一次会议毁掉整个下午的方式,是把下午切成两块——每一块都小到干不成任何需要脑力的硬活。他还有一句更狠的比喻:对处于制造者时刻表的人来说,日程上有一场会,就像程序里埋了一个异常——你还没执行到它,它已经开始影响你了。
Joel Spolsky 在 2001 年的《Human Task Switches Considered Harmful》里讲的是同一件事的另一面:打断的成本并不等于打断本身的耗时,恢复「我当时在想什么」才是大头。
这里我要踩一脚刹车,也是本文的一条自觉:流行数字不要照抄。比如「被打断后要 23 分钟才能重回状态」,比如 Gerald Weinberg 那张著名的「同时进行的项目数 × 切换损耗」表(出自 Quality Software Management: Systems Thinking,Dorset House,1992,第 284 页,常被转述为「2 个项目损失 20%、5 个项目损失 75%」)——这些转述在各版本之间并不一致,我没有条件在本文里替它们逐字背书。
所以换个办法:不用别人的数字,用你自己仓库里那份。它小、脏、不完美,但它是你的,而且你今天就能拿到。
二、第一份数据:22:00 的「高产神话」
下面这段脚本只依赖 Node.js 和 git。它分三步:
- 从
git log里取出全部提交的时间、标题、改动文件(%aI给的是 ISO 格式的 author date,带时区偏移); - 先找出「不是你干的活」——用一条偏执的规则筛出定时任务时段;
- 把剩下的提交按时间切分成「工作会话」,算三个指标。
// fragments.mjs —— 用 git 提交时间戳重建「工作会话」,测量你的一天被切成多少段// 用法: node fragments.mjs [--since="1 year ago"] [--gap=45] [--tz=+08:00]import { execFileSync } from 'node:child_process';
const argv = Object.fromEntries( process.argv.slice(2).map((a) => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }),);const since = argv.since ?? '1 year ago';const gapMin = Number(argv.gap ?? 45); // 间隔超过这么久,就认定中间换过一次上下文const tz = argv.tz ?? '+08:00';const OFFSET_MS = (tz[0] === '-' ? -1 : 1) * (Number(tz.slice(1, 3)) * 60 + Number(tz.slice(4, 6))) * 60000;
// ---------- 采集 ----------// 一条 git log 拿全:时间、标题、改动文件。%x1f 是字段分隔符,比按空格切安全const RAW = execFileSync( 'git', ['log', `--since=${since}`, '--format=%aI%x1f%s%x1f', '--numstat'], { encoding: 'utf8', maxBuffer: 1 << 28 },);
const ISO = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[+-]\d{2}:\d{2}|Z)/;const commits = [];let cur = null;for (const line of RAW.split('\n')) { const head = line.match(ISO); if (head && line.includes('\x1f')) { if (cur) commits.push(cur); // 统一换算到「作者所在地的墙上时间」,否则 UTC 会把一天劈成两半 cur = { ts: Date.parse(line.slice(0, head[0].length)) + OFFSET_MS, day: head[1], hour: Number(head[2]), minute: Number(head[3]), subject: line.split('\x1f')[1] ?? '', dirs: [], }; continue; } if (cur && line.includes('\t')) { const p = line.split('\t'); if (p.length >= 3) { const path = p.slice(2).join('\t'); cur.dirs.push(path.includes('/') ? path.split('/').slice(0, 2).join('/') : path); } }}if (cur) commits.push(cur);
// ---------- 输出小工具 ----------const bar = (n, max, width = 28) => '█'.repeat(Math.max(1, Math.round((n / max) * width)));const showHist = (list, title) => { const h = Array.from({ length: 24 }, (_, i) => list.filter((c) => c.hour === i).length); const max = Math.max(...h); console.log(`\n${title}`); h.forEach((n, i) => { if (n) console.log(` ${String(i).padStart(2, '0')}:00 ${String(n).padStart(3)} ${bar(n, max)}`); });};
// ---------- 阶段 1:没见过滤的原始分布 ----------console.log(`窗口 ${since},共 ${commits.length} 条提交,按作者所在时区 ${tz} 统计`);showHist(commits, '① 原始分布:提交落在哪些钟点');
// ---------- 阶段 2:挑出「守时得不像人」的时段 ----------// 判据:某个钟点内提交数 === 出现过的天数(即每天至多一条),且横跨 >= 7 天。// 人会一天提交很多次、也会缺席;定时任务才会一天一条、从不落下。const flagged = new Set();for (let h = 0; h < 24; h++) { const inHour = commits.filter((c) => c.hour === h); const days = new Set(inHour.map((c) => c.day)); if (days.size >= 7 && inHour.length === days.size) flagged.add(h);}const scheduled = commits.filter((c) => flagged.has(c.hour));
console.log( `\n② 疑似定时任务时段:${[...flagged].map((h) => `${String(h).padStart(2, '0')}:00`).join('、') || '无'}`,);if (scheduled.length) { console.log(` 该时段 ${scheduled.length} 条,样例(规则会误伤,务必人工过一遍):`); for (const c of scheduled.slice(0, 4)) { console.log( ` ${c.day} ${String(c.hour).padStart(2, '0')}:${String(c.minute).padStart(2, '0')} ${c.subject.slice(0, 44)}`, ); }}
// ---------- 阶段 3:剔除后重算 ----------const human = commits.filter((c) => !flagged.has(c.hour)).sort((a, b) => a.ts - b.ts);showHist(human, '③ 清洗后:真正的作息');
const sessions = [];for (const c of human) { const last = sessions.at(-1); if (last && c.ts - last.lastTs <= gapMin * 60000) { last.lastTs = c.ts; last.commits.push(c); } else sessions.push({ startTs: c.ts, lastTs: c.ts, commits: [c] });}
const median = (xs) => { const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;};const fmt = (m) => (m >= 60 ? `${(m / 60).toFixed(1)}h` : `${Math.round(m)}m`);const dur = (s) => (s.lastTs - s.startTs) / 60000;
console.log(`\n④ 工作会话(相邻提交间隔 > ${gapMin} 分钟即断开)`);const single = sessions.filter((s) => s.commits.length === 1).length;const multi = sessions.filter((s) => s.commits.length > 1);let switches = 0;for (const s of multi) switches += new Set(s.commits.flatMap((c) => c.dirs)).size - 1;console.log( ` 会话 ${sessions.length} 个,只有一条提交的 ${single} 个(${Math.round((single / sessions.length) * 100)}%)`,);console.log(` 时长:中位 ${fmt(median(sessions.map(dur)))},最长 ${fmt(Math.max(...sessions.map(dur)))}`);console.log( ` 多提交会话 ${multi.length} 个,平均每个跨越 ${(switches / multi.length).toFixed(1)} 个模块`,);
console.log('\n⑤ 提交间隔分布(这段空档是分心、换任务,还是下班)');const gaps = human.slice(1).map((c, i) => (c.ts - human[i].ts) / 60000);const buckets = [ ['< 5 分钟(还在同一次心流里)', (g) => g < 5], ['5–15 分钟', (g) => g >= 5 && g < 15], ['15–45 分钟(一次分心)', (g) => g >= 15 && g < 45], ['45 分钟–2 小时(换了任务)', (g) => g >= 45 && g < 120], ['2–8 小时(半天)', (g) => g >= 120 && g < 480], ['> 8 小时(过夜)', (g) => g >= 480],];for (const [label, test] of buckets) { const n = gaps.filter(test).length; console.log(` ${label.padEnd(26)} ${String(n).padStart(3)} ${bar(n, gaps.length)}`);}console.log(` 间隔中位数 ${fmt(median(gaps))}`);
console.log('\n⑥ 一天的碎片化程度');const byDay = new Map();for (const c of human) byDay.set(c.day, [...(byDay.get(c.day) ?? []), c]);const perDay = [...byDay.keys()].map((d) => sessions.filter((s) => s.commits[0].day === d).length);const frag = perDay.filter((n) => n >= 3).length;console.log(` 有提交的 ${byDay.size} 天里,平均每天 ${(sessions.length / byDay.size).toFixed(1)} 个会话`);console.log(` 一天冒出 >= 3 个独立会话的:${frag} 天(${Math.round((frag / byDay.size) * 100)}%)`);console.log( ` 提交最多的 5 天:${[...byDay.entries()] .sort((a, b) => b[1].length - a[1].length) .slice(0, 5) .map(([d, v]) => `${d}(${v.length})`) .join(' ')}`,);跑在自己这个博客仓库上(1 年窗口,共 318 条提交),第一阶段的结果是这样:
窗口 1 year ago,共 318 条提交,按作者所在时区 +08:00 统计
① 原始分布:提交落在哪些钟点 00:00 2 █ 02:00 2 █ 03:00 3 █ 04:00 11 █████ 05:00 1 █ 06:00 1 █ 07:00 2 █ 08:00 5 ██ 09:00 18 █████████ 10:00 37 ██████████████████ 11:00 24 ████████████ 13:00 9 ████ 14:00 15 ███████ 15:00 27 █████████████ 16:00 24 ████████████ 17:00 28 ██████████████ 18:00 5 ██ 19:00 20 ██████████ 20:00 13 ██████ 21:00 12 ██████ 22:00 58 ████████████████████████████ 23:00 1 █第一反应很容易是自恋的:「看,22:00 是我一天里效率最高的时候,深夜才是我的黄金时段。」
这个结论如果就这么写进文章,那就是我编的。所以下一步是追问那一小时里到底发生了什么。挑几条出来看标题,很容易就发现事情不对:它们清一色是 feat: 新增博文「…」,时间戳全部落在 22:17–22:27 这个十分钟窗口内,横跨连续 40 多天,一天一条,从不缺席。
这是本站的「每日博文」流水线按固定的时间点提交的产物,不是人的作息。于是脚本里那条偏执的规则可以派上用场了——某个钟点里提交数恰好等于出现过的天数(每天至多一条),且至少横跨 7 天,就判为定时任务时段:
② 疑似定时任务时段:22:00 该时段 58 条,样例(规则会误伤,务必人工过一遍): 2026-10-13 22:08 fix: 动图绕过图片管线,修复侧栏头像不再动 (#97) 2026-10-12 22:21 feat: 新增博文「前端错误监控实战:从 window.onerror 到 Sourc 2026-10-11 22:19 feat: 新增博文「Code Review 人间百态:LGTM 背后的真相」 2026-10-10 22:19 feat: 新增博文「摸鱼的艺术:办公室生存指南」注意打印出来的第一条:它是那天晚上真实发生的一次修复,只是因为恰好落在 22:08,被这条钟点级规则误伤了。规则剔的是「小时」,不是「提交」;宁可多剔一条真人提交,也不要把机器噪音留在图里——但被剔掉的东西必须打印出来给人过目,这是这类清洗唯一靠谱的用法。
这一节真正的教训,和技术债分析里 pnpm-lock.yaml 霸榜是同一类错误:任何「我的时间都去哪了」的分析,第一问必须是「这些活动里,哪些根本不是我干的」。 不只是定时任务:CI、代码格式化机器人、依赖升级 bot、生成器脚本,都会在你的行为日志里以你的名义活动。顺手说一句,这份数据里还有 6 条来自 dependabot 和 Cloudflare 的机器人账号,量小且分散,这次我没单独处理——但你知道它们存在。
三、清洗之后,真正的作息长什么样
把 22:00 整个钟点剔掉(58 条),剩下 260 条:
③ 清洗后:真正的作息 00:00 2 ██ 02:00 2 ██ 03:00 3 ██ 04:00 11 ████████ 05:00 1 █ 06:00 1 █ 07:00 2 ██ 08:00 5 ████ 09:00 18 ██████████████ 10:00 37 ████████████████████████████ 11:00 24 ██████████████████ 13:00 9 ███████ 14:00 15 ███████████ 15:00 27 ████████████████████ 16:00 24 ██████████████████ 17:00 28 █████████████████████ 18:00 5 ████ 19:00 20 ███████████████ 20:00 13 ██████████ 21:00 12 █████████ 23:00 1 █形状变了:最高峰从 22:00 换成了 10:00(37 条),17:00(28)、15:00(27)、16:00(24)、11:00(24)跟在后面。真实作息是上午一段、下午一段,晚上零星补几笔——这是典型的「制造者时刻表」被打散之后的样子。
但别急着给图配故事。看 04:00 那 11 条。顺手加一条 --grep 就能查到它们的真身:
$ git log --since="1 year ago" --format="%aI %s" | grep 'T04:'2026-09-02T04:18:31+08:00 Merge pull request #39 from HONGLEI111/daily-draft/2026-09-012026-08-20T04:38:45+08:00 chore: remove dead code and tidy project config2026-08-20T04:35:18+08:00 perf: compress banner source and regenerate og-image2026-08-20T04:34:02+08:00 perf: bundle only active i18n dictionaries2026-08-20T04:32:40+08:00 perf: render each entry's markdown once for metadata2026-08-20T04:31:27+08:00 security: sanitize pagefind excerpts before rendering as HTML2026-08-20T04:30:01+08:00 refactor: consolidate duplicate PostMeta/PostCard/PostPage components...其中 9 条集中在 2026-08-20 的 04:23–04:38 这 15 分钟里。「15 分钟 9 条提交」不等于「15 分钟干了 9 件事」:这 9 条合起来改了 +1892/−2341 行、触达 86 次文件,是一次把大改动拆成可回滚小步的重构(还有几个 perf/security 的顺手项)。提交粒度是一个选择——你完全可以把同一件事拆成 9 条,也可以攒成 1 条。所以:
钟点分布量的是「产出落在哪」,不是「你在哪个钟点劳动」。
这条区分后面还会再咬我们一口。
四、把提交切回「会话」
单个提交看不出节奏,得把它们连起来看。规则很朴素:相邻两次提交间隔超过 45 分钟,就认为中间发生了一次上下文切换,前后属于两个不同的工作会话。
45 分钟这个数不是从哪篇论文抄来的,是调出来的:调太小(15 分钟),同一段连续工作会被切成一堆假会话;调太大(2 小时),午休回来接着干和上午的活会被错误地粘成一段。这个阈值你该按自己的工作形态改,但必须固定下来——否则每次跑出来的数都不一样,它就不能用来比较。
④ 工作会话(相邻提交间隔 > 45 分钟即断开) 会话 116 个,只有一条提交的 63 个(54%) 时长:中位 0m,最长 2.5h 多提交会话 53 个,平均每个跨越 3.4 个模块
⑤ 提交间隔分布(这段空档是分心、换任务,还是下班) < 5 分钟(还在同一次心流里) 73 ████████ 5–15 分钟 37 ████ 15–45 分钟(一次分心) 34 ████ 45 分钟–2 小时(换了任务) 23 ██ 2–8 小时(半天) 25 ███ > 8 小时(过夜) 67 ███████ 间隔中位数 26m
⑥ 一天的碎片化程度 有提交的 70 天里,平均每天 1.7 个会话 一天冒出 >= 3 个独立会话的:13 天(19%) 提交最多的 5 天:2026-10-13(22) 2026-08-18(19) 2026-09-01(15) 2026-03-23(13) 2026-08-20(11)把这几行翻译成人话:
- 一半以上的会话只有一条提交(63/116,54%)。也就是说,我的产出更像是「点状」的,而不是「带状」的:动手一次、提交一次、然后隔开。
- 会话时长的中位数是 0 分钟——这不是 bug,是单提交会话的定义使然:只有一条提交,首末时间自然相同。这行数字的价值在于提醒你别把中位数当时长读。
- 多提交会话平均横跨 3.4 个模块。这是「我确实在一次会话内部就在切」的直接证据:即便不算上被外部打断的部分,我自己的主动切换也足够频繁。
- 一天平均 1.7 个会话,19% 的日子冒出 3 段以上。碎片化不是天天发生,而是集中在少数几天——这很符合直觉:真正忙乱的日子,是那种同时被塞进三件事的日子。
最长的那段会话是 2026-03-23 的 13:44–16:13(2.5 小时,12 条提交)。它的标题序列长这样:
13:44 feat: add decap cms14:36 add admin14:59 update config15:18 change config15:40 change bashurl15:43 change netlify15:50 change Netlify16:13 updated github12 条提交,但只做了一件事:把 CMS 装上、把部署配置调通。这条会话里,提交数是尝试次数,不是任务数,更不是效率。看到「某天提交很多」就欢呼,和看到「某天提交为零」就自责,是同一种误读。
顺便对照一下最忙的那天——2026-10-13 有 22 条提交,但它们被切成 6 段(14:56 到 21:41),中间夹着四次 PR 合并和一条 agent skills 配置。这是典型的「多线程的一天」:不是干了六件事,而是同一件事被切了六次。
五、这个口径能测什么、不能测什么
这类”自我量化”的文章最容易翻车的地方,就是把一个有解释力的代理指标吹成真相。所以把边界写清楚:
| 指标 | 能回答 | 不能回答 |
|---|---|---|
| 钟点分布 | 产出落在一天的哪几段 | 你几点开始工作——完全可能夜里写完、早上补提交 |
| 会话切分 | 产出的形状是「带状」还是「点状」 | 你实际专注了多久(见下) |
| 间隔分布 | 上下文切换频率的下界:每两次提交之间至少切过一次 | 切给了谁,是不是被会议、同事、通知打断的 |
| 每日会话数 | 一天里你「重新起手」了多少次 | 这些起手是别人造成的,还是你自己巡逻出来的 |
| 会话内模块切换 | 你的主动切换有多频繁 | 每次切换的实际损失(有的模块相邻,切了也不痛) |
最要命的一条是第二行和第三行背后的同一个事实:提交是里程碑,不是劳动。 一次性写了三小时、结尾提交一次,在这个口径里就是「一个 0 分钟的单提交会话」。反过来,把重构拆成 9 个可回滚的小步(就像上面 08-20 那批),会呈现出「一段 15 分钟的密集会话」。
所以准确的说法是:这个脚本测的是上下文切换的下界,不是专注的总量。 它不告诉你今天专注了几个小时,它告诉你产出被摊成了多少段、段与段之间隔了多远。
要让这个数变得更有用,唯一的办法是多源对齐:把 git 时间戳和日历、终端 history、编辑器会话记录、CI 构建日志放在同一根时间轴上。单独看,它只是一份形状;和「15:00 那个会议」叠在一起,它才能回答「是谁切了你的时间」。我自己这个仓库里就有现成的第二个数据源——里面还留着一次 9 条提交的批处理(08-20 凌晨),下次分析时它可以用来给那次夜间时段定性:是集中交付,还是顺手把攒着的改动一次推了。
六、把碎片粘回整块:四件今天就能做的事
数据本身不会让你更专注,但它能让你把力气用在有杠杆的地方。按上面的数字,四件事按收益从高到低排:
1. 给中断排队,而不是给中断让路。 既然 54% 的会话是孤立的单提交,说明「随时响应」模式已经把你的产出打成了散点。具体做法不是「少看消息」(这靠意志力,靠不住),而是把响应时间点固定下来:每天两到三个固定时段集中处理 review、聊天和通知,其余时间关掉推送。批处理的价值不在于省下消息时间,而在于让别人能预期你的响应节奏——被打断的次数往往来自别人不知道你什么时候在线。
2. 限制同时推进的任务数。 上面那张 Weinberg 的表格虽然数字存疑,但它的形状是对的:并行数几乎是唯一你能直接控制、又直接乘进损耗里的变量。实操上给一个能执行的硬约束:同时进行、未合并的任务不超过 2 个,并且写下来(issue、便签、终端里的 todo 都行)。「同时在做 4 件事」的感觉通常来自没有记录——写下来之后你会发现其中两件其实停在原地。
3. 给「下一个人」写交接,那个下一个人往往就是几小时后的你。 上下文切换最贵的部分不是切换动作,而是重建「我当时在想什么」。既然每次会话结束前都必然要停手,那就把重建成本外化:提交信息里写一行「下一步」,issue 里留一句「卡在哪」,或者干脆在编辑器里留一条 TODO。这个动作 30 秒,能省掉第二天早上 10 到 20 分钟的重新考古——这也是为什么我自己仓库里那些 change config 式的提交很值得反思:它们没有任何一条告诉未来的我「为什么改」。
4. 保护至少一个半天的整块。 这一条直接来自 Paul Graham 那张时刻表:制造者的半天不该被切开。操作上就是把会议往一天的两端压(早上开场或下午收尾),让中间至少留下一块不被打断的三小时。做不到每天,就锁定每周两天。
最后,把脚本当作反馈回路,而不是成绩单。两个月跑一次,只盯三个数:单提交会话占比(降下来,说明产出在成块)、每天平均会话数(降下来,说明一天被重新起手的次数少了)、≥3 段的日子比例(降下来,说明忙乱的日子在变少)。不要把它们合成一个「效率分」——三个数各有各的失效模式,一旦合成一个分数,人就会开始优化这个分数。
结语
深度工作不是意志力问题,它是一份可以测量的账。
你没法消除中断——同事会找你,会议会开,构建会挂。但你可以知道自己的时间被切成了什么形状,可以决定这些切口出现在哪些时段,也可以在下一次被打断之后,用 30 秒的交接把自己接回来。
那 58 条挤在 22:19 的提交给了一个额外提醒:在评价自己之前,先确认那张图上画的确实是你。 有些疲惫来自打扰,有些「效率」来自 cron。
参考来源
- Paul Graham, Maker’s Schedule, Manager’s Schedule(2009):两种时刻表的划分,以及「一次会议会把下午切成两块、每块都小到干不成硬活」的原文出处
- Joel Spolsky, Human Task Switches Considered Harmful(2001):任务切换的代价不止于切换动作本身,本文第一节的论点之一
- Gerald M. Weinberg, Quality Software Management: Systems Thinking, Dorset House, 1992, p. 284:常被引用的「并行项目数与切换损耗」表;各版本转述数字不一致,本文仅取其「损耗随并行数非线性上升」的形状
- Git 官方文档:
git log:本文用到的--format=%aI、--numstat、--since的语义(%aI为作者本地时间,带时区偏移)