侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

git bisect run 找出引入 bug 的提交

2026-9-15 / 0 评论 / 15 阅读

git bisect run 找出引入 bug 的提交

测试环境上一个功能挂了,日志里翻不出东西,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 翻一遍比搭一套判定脚本快。

这套工具的价值完全等于判据本身的可靠性。判据可信,十几次检出能省下一个下午;判据有副作用或者依赖缓存,它会安静地指着一个无关的提交,白查一遍。你手上那条判据,现在经得起连跑两遍吗?

    🤞 分享