
测试环境上一个功能挂了,日志里翻不出东西,git log 一拉,两周两百多个提交。手工二分的流程是这样的:把工作区退到中间那个提交,跑一遍测试,再退到剩下区间的一半,来回十几次,还得记着自己刚才试到哪儿了。git bisect run 接管的就是这段体力活:给一条命令,剩下的它自己跑。
下面是九步,从零一直到手里攥着一个确定的坏提交。每一步都能单独执行,中间插了检查点,跑出来对不上就先停住别往下走。在 /tmp 里造了个 14 个提交的仓库把这条路走了一遍,文中的输出是这台机器上真跑出来的(macOS 26.2,git 2.50.1),脚本存档在 tools/verify-git-bisect.sh。
第 1 步:把判据写成一条只看退出码的命令
交给 git bisect run 的就是一条普通命令,它不解析输出,只看三个数字:0 表示这个提交是好的,非 0 表示坏的,125 表示这个提交没法测、跳过。命令是 node、pytest 还是 make,它都不管。
测试文件长这样,完整可复制:
const { double } = require("./util");
const cases = [[3, 6], [-4, -8], [0, 0], [7, 14]];
let bad = 0;
for (const [input, want] of cases) {
const got = double(input);
const ok = got === want;
if (!ok) bad++;
console.log(`double(${input}) = ${got}, want ${want} ${ok ? "ok" : "FAIL"}`);
}
process.exit(bad ? 1 : 0);
第 2 步:在两头各确认一次
先在 HEAD 上跑一遍,确认判据有效,再开始二分。这一步不碰 git 的二分状态,单看退出码就行。
检查点:坏提交那一头跑出 1,好提交那一头跑出 0。有一头对不上,说明判据本身有问题,先停下来修判据。
第 3 步:起二分,坏提交写在前面
git bisect start <坏提交> <好提交>
git bisect run node test.js
坏提交写在前面。一开始写反了,git 直接说:
Some good revs are not ancestors of the bad rev.
git bisect cannot work properly in this case.
Maybe you mistook good and bad revs?
检查点:
git bisect start那行不该有任何抱怨。只要冒出mistook good and bad revs,就是两个参数的位置反了。
第 4 步:让它自己跑完,14 个提交四轮收敛
真跑起来是这样,中间那些断言输出略去了:
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[efdf1e148ce9877eb36b295bbc443a1145b73ef5] c8: tidy notes
running 'node' 'test.js'
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[38ebac4eb5bfe1613e2f3787d6dec6ef755d292b] c11: 无关改动
running 'node' 'test.js'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[bcc49d38d0b98b4d215a8b111d4cadc924e5a802] c10: 加了未完成的 draft.js
running 'node' 'test.js'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[f23eda99696c197a2e640bd7561fdc7ded7fa40a] c9: 优化 double 的实现
running 'node' 'test.js'
f23eda99696c197a2e640bd7561fdc7ded7fa40a is the first bad commit
util.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
bisect found first bad commit
14 个提交试了 4 次,跟 log2(14)≈3.8 对得上。它每一轮都把「还剩几个提交没查、大概还要几步」打出来,长区间也不会让人心里没底。真正跑测试之前它会 checkout 整个提交,所以工作区里的文件是真的被换过的,不是只看 diff。
提交区间给 sha 前几位就行,给 tag 或者分支名也行。
检查点:最后一行要出现
is the first bad commit。少了这一行,说明它中途停了,回头去看上面那段报错。
第 5 步:收敛之后第一件事是 reset
最后一个提示 bisect found first bad commit 之后,HEAD 是游离的:
f23eda9 c9: 优化 double 的实现
## HEAD (no branch)
我当时顺手在那个状态里改了个文件并且 commit,然后 git bisect reset,git 给了这么一段:
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
3a15df0 顺手提交了
那个提交还在对象库里,但没有任何分支指向它,只能靠 sha 捞。所以一条实用的规矩是:跑完 bisect 第一件事就是 git bisect reset,在那之前不要写代码也不要提交。
检查点:
git status回到## main,游离状态消失,工作区干净。
第 6 步:检查判据有没有副作用
bisect 本身不判断对错,它只是在给定的判据上做二分,判据一旦不可信,它给出的结论会带着同样的自信。最常见的一类脏判据是往工作区里写东西,比如让判据每次运行都往 util.js 末尾追加一行打点日志:
#!/bin/bash
printf '\n// 打点日志\n' >> util.js
node test.js
结果是第二次检出直接失败:
error: Your local changes to the following files would be overwritten by checkout:
util.js
Please commit your changes or stash them before you switch branches.
Aborting
Bisecting: 2 revisions left to test after this (roughly 2 steps)
error: bisect run failed: 'git bisect good' exited with error code -1
退出码 1,二分中止,工作区还留着一份脏改动,连 git bisect reset 都不干净,得再补一句 git checkout -- util.js。测试脚本要么只读,要么把临时文件写到仓库外面去,日志文件也算。
检查点:手动把判据连跑两次,两次跑完
git status都得是干净的。
第 7 步:检查判据有没有缓存
这个更阴。脚本第一次跑完把结果存进 .testcache,后面直接复用:
#!/bin/bash
if [ -f .testcache ]; then
exit "$(cat .testcache)"
fi
node test.js
echo $? > .testcache
bisect 拿到的是:
[e7633609c1dec68168c1004811cffd46578d84ff] c8: tidy notes
[1db85d989fd14146c9bf026e7a4b7ca438a8e0c7] c13: 无关改动
running './cached-test.sh'
ae80378ce9489456871c21b7c09e577d417d00fe is the first bad commit
c14: 无关改动
bisect found first bad commit
git bisect run 退出码 0
它把 c14 判成了首个坏提交,那是一个只动过 notes.js 的提交,跟这次的 bug 毫无关系。真凶是 c9。整个过程退出码 0,一句警告都没有。
现实里对应的东西是构建缓存、测试框架的结果缓存、node_modules/.cache。判定命令要保证每个提交都是从头算的,跑之前把缓存目录删掉是最省事的办法。
检查点:把
.testcache这类文件删掉再跑一次,坏提交的 sha 应该和第 4 步一模一样。
第 8 步:测不了的提交返回 125
仓库历史里有语法错误的 WIP 提交很正常,这种提交上判据根本没机会跑,判成坏的会把结论带偏。正确做法是返回 125:
#!/bin/bash
if [ -f draft.js ] && ! node --check draft.js 2>/dev/null; then
exit 125
fi
node test.js
这段历史里 c10 之后带着一个语法错误的 draft.js,用它跑,bisect 跳过了那几个提交,两轮就收敛到了 c9。跳过的前提是克制,如果每个提交都返回 125,你会得到:
We cannot bisect more!
error: bisect run cannot continue any more
退出码 2。另外,命令路径写错不会算成坏提交,git 会单独报出来:
error: bogus exit code 127 for good revision
还有一种情况是两个端点都给错了。git bisect start <坏提交> <坏提交的上一个> 这种写法 bisect 不报错,它直接把后者宣布成首个坏提交,退出码 0。它只做二分,不做验证,起点还是要自己确认。
检查点:start 之前把两个 sha 自己看一眼,别把两个坏的都塞给它。
第 9 步:把整场过程留档
git bisect log 会输出整场会话,12 行左右,git bisect replay 能照着重放一遍:
git bisect log > /tmp/bisect-session.log
git bisect reset
git bisect replay /tmp/bisect-session.log
重放的结果和第一次一致。把这份 log 贴进 issue 或者 PR,比写一句「这条已经二分过了,先回滚 c9」有说服力得多,因为每一步判据都写在里面。
走完九步还是没捞到东西的话
判据不稳定的时候,bisect 会先要求把测试修好。同一个提交跑两次结果不同,二分出来的提交号没有任何意义,先把 flake 处理掉再回来。
编译一次要十分钟的项目也不太划算。14 个提交四轮下来就是四十分钟起步,配上 ccache 这类编译缓存才值得做。
范围小的时候手工看更直接。如果嫌疑范围就三五个提交,git log -p 翻一遍比搭一套判定脚本快。
这套工具的价值完全等于判据本身的可靠性。判据可信,十几次检出能省下一个下午;判据有副作用或者依赖缓存,它会安静地指着一个无关的提交,白查一遍。你手上那条判据,现在经得起连跑两遍吗?