12k
All articles

使用 git bisect 定位问题提交

使用 git bisect 定位导致回归的提交。选择可靠的基准版本,自动运行测试,处理不稳定结果与跳过项,并验证问题提交。

OpenReplay Team
OpenReplay Team
使用 git bisect 定位问题提交

git bisect 通过二分查找来定位引入回归问题(regression)的提交。你只需标记一个已知正常的提交和一个已知有问题的提交,之后每次测试都会把剩余范围缩小一半。因此,即使有 300 个提交,也只需检查大约八到九次。

这个功能上个月还好好的。从那以后,main 分支上合入了 300 个提交,没人记得自己动过购物车(cart)相关的代码,而逐个阅读每个 diff 得花掉整个下午。

5 Git Commands Beyond Commit and Push 一文介绍了 start、good、bad、run、skip 和 reset 这几个子命令。本文将更进一步,讨论以下问题:如何选出一个真正可信的正常提交;如何编写一个退出码不会误导 Git 的 git bisect run 脚本;以及当不稳定测试(flaky test)或错误标记把搜索引向错误方向时该怎么办。

核心要点

  • bisect 范围每扩大一倍,只会多出一次检查。因此,把正常提交选得过于久远,真正的代价在于:那些已无法构建的旧提交只能被跳过。
  • 使用 git bisect run 时,退出码 0 表示将提交标记为正常,125 表示跳过,1 到 127 之间的其他退出码表示标记为有问题,128 及以上则会中止 bisect。
  • 对于间歇性故障,只要多次运行中有任何一次失败,就将该提交标记为有问题;只有全部运行都通过时,才标记为正常。
  • 标记错误并不意味着要从头再来:先保存 git bisect log 的输出,删除错误的那一行及其之后的所有内容,然后执行 git bisect reset 和 git bisect replay。
  • 通过测试被指认的提交及其父提交来确认结果:父提交应当通过测试,而被指认的提交应当失败。

手动执行 git bisect 循环

手动循环只需三条命令即可启动:先执行 git bisect start,然后对一个有问题的提交执行 git bisect bad,再对一个正常的提交执行 git bisect good <ref>。之后,Git 每检出一个提交,你就测试一次并进行标记,直到 Git 指认出问题提交为止。Pro Git 中关于使用 Git 调试的章节也介绍了相同的流程。如果你刚接触 Git,可以先阅读 10 Git commands every developer should know,了解日常使用的基础知识。

git bisect start
git bisect bad              # HEAD has the bug
git bisect good v4.12.0     # last release known to work
Bisecting: 149 revisions left to test after this (roughly 7 steps)
[9e41c07b2d85a3f16c0e7b94d2a58f3e1c6b0d27] Refactor cart line-item formatter

Git 已经检出了中间位置的提交。运行你的测试,并报告结果:

git bisect bad
Bisecting: 74 revisions left to test after this (roughly 6 steps)
[2b7f0d9a4c16e83b5f2a07d9c4e1b68a3f05c9d1] Update currency util types

继续测试并标记。当只剩下一个候选提交时,Git 会输出结果(以下 SHA 和名称均为占位符):

3c9d2e7a51f04b8e96d1a2c7f05e4b3d8a6c1f92 is the first bad commit
commit 3c9d2e7a51f04b8e96d1a2c7f05e4b3d8a6c1f92
Author: Example Dev <dev@example.com>
Date:   <date>

    Round line totals before applying discount

 src/cart/total.ts | 4 ++--

如何选择正常提交?

对 git bisect 而言,最理想的正常提交是:你确知发布时不存在该 bug 的最近一个发布标签(release tag),并且在标记之前亲自测试过。如果某个被标记为正常的 ref 实际上是有问题的,bisect 依然会给出一个看似确凿的答案(通常是紧跟在该 ref 之后的某个提交),但这个答案是错的。

请使用与 bisect 相同的检查方式来测试该标签(此处的 Vitest 仅作为测试运行器的示例):

git switch --detach v4.12.0
npm ci && npx vitest run src/cart/total.test.ts   # must pass
git switch -

不要为了缩小范围而牺牲确定性。范围每扩大一倍,只会多出一次检查:300 个提交大约需要 8 到 9 次检查,600 个需要 9 到 10 次,2,400 个需要 11 到 12 次。追溯得太远,代价体现在别处:旧提交可能需要更老的 Node 版本、某个已被撤销发布(unpublish)的依赖,或者不同格式的 lockfile。每个无法构建的提交都只能被跳过,而一长段被跳过的区间可能恰好把问题提交藏在其中。

会话回放(session replay)可以显示回归问题最早是在何时影响到真实用户的,从而把正常提交缩小到在此之前部署的那个版本。

git bisect run 如何实现自动化搜索?

git bisect run <script> 会在每个候选提交上运行你的脚本,并将脚本的退出码作为判定结果。git-bisect 官方文档定义了如下映射关系:

退出码含义
0正常(good)
1–127(125 除外)有问题(bad)
125跳过(无法测试)
128 及以上中止 bisect

请将脚本放在仓库之外。这样,检出旧提交时脚本不会被改动,git clean -fdx 之类的清理命令也不会把它删掉:

cat > ../bisect-test.sh <<'EOF'
#!/usr/bin/env bash
npm ci --silent        || exit 125   # can't install: skip
npm run build --silent || exit 125   # can't build: skip
npx vitest run src/cart/total.test.ts || exit 1
EOF
chmod +x ../bisect-test.sh
git bisect run ../bisect-test.sh

安装或构建失败时以 125 退出,因此与本问题无关的故障会被跳过,而不会被误判为有问题。末尾的 || exit 1 会把所有测试失败统一映射为 1,这样即便测试运行器恰好返回 125 或 128 以上的退出码,也不会意外跳过某个提交或中止整个运行过程。

退出码 126(不可执行)和 127(命令未找到)通常会被视为有问题。也就是说,脚本路径写错或缺少执行权限,都可能导致每个提交都被标记为有问题。Git 2.36 的发布说明介绍了相应的保护机制:Git 会尝试识别无法运行的脚本,并提前停止,而不是指认出一个问题提交。如果 git bisect run 提前停止,请先检查脚本路径和权限,再去排查代码。

常见问题:跳过、重置与未提交的改动

大多数中断都源于三种情况:某个提交无法测试、需要结束当前会话,或者本地改动造成干扰。

git bisect skip

git bisect skip 会搁置当前提交,Git 随后转到附近的另一个提交。如果问题提交最终落在一段被跳过的提交之中,Git 会列出所有候选提交,而不是指认其中某一个。

git bisect reset              # back to the branch you started on
git bisect reset 3c9d2e7      # end the session on a specific commit

在执行 git bisect start 之前,请先提交或 stash 本地改动。你可以使用 git bisect start -- src/cart/ 将搜索范围限定在特定路径内。git bisect visualize 会用 gitk 打开剩余的可疑提交;如果 Git 未检测到图形桌面会话,则会回退为 git log。

如何处理不稳定测试和错误标记?

要对间歇性故障进行 bisect,需要在每个提交上多次运行测试:只要有任何一次失败,就标记为有问题;只有全部通过时,才标记为正常。原因在于,哪怕只有一次标记错误,搜索也会进入错误的那一半区间,而 Git 依然会报告一个“首个有问题的提交”。真正有问题的提交可能侥幸通过测试,但正常的提交绝不应该在此测试中失败。

#!/usr/bin/env bash
npm ci --silent        || exit 125
npm run build --silent || exit 125
for i in $(seq 1 10); do
  npx vitest run src/cart/total.test.ts || exit 1   # any failure = bad
done
exit 0                                              # all passed = good

以一个假设情况为例来算一笔账:如果某个有问题的提交有 20% 的概率失败,那么连续十次都碰巧通过的概率为 0.8¹⁰ ≈ 0.11。运行次数越多,这一风险就越小。跳过结果不明确的提交并不能解决问题,因为不可靠的结果依然存在,只会让最终结果的范围变得更宽。此规则的前提是:不稳定性是新引入的。如果在回归发生之前该测试就已经不稳定,请先修复它或编写一个更有针对性的检查,否则它会把正常提交误标为有问题。

如果你已经做了错误的标记,无需从头再来,可以这样修正:

git bisect log > ../bisect.log
# edit ../bisect.log
git bisect reset
git bisect replay ../bisect.log

保存下来的日志中还包含注释行,这里只展示命令行。编辑前:

git bisect start
git bisect bad 8d1f...
git bisect good 5a0c...
git bisect good 9e41...    <- wrong: this commit was flaky
git bisect bad 2b7f...

编辑时,删除错误的那一行及其之后的所有内容,编辑后:

git bisect start
git bisect bad 8d1f...
git bisect good 5a0c...

git bisect replay 会将会话恢复到最后一次正确标记的状态,然后你就可以从那里继续。

如何确认结果?

在亲自验证之前,请把 git bisect 指认的提交仅仅视为一条线索。先用 git show 3c9d2e7 阅读其 diff,然后分别测试该提交的父提交和该提交本身:

git switch --detach 3c9d2e7^   # parent: test should pass
git switch --detach 3c9d2e7    # culprit: test should fail

如果父提交通过而该提交失败,就说明你已经找到了引入回归的提交。接下来,可以使用 git blame 查看周边代码行为何被修改,并借助改写 git 历史的方法修正尚未推送的提交。下次再遇到回归问题时,请从一个经过测试的发布标签出发,并使用一个在每个提交上多次运行测试的脚本。

常见问题解答

git bisect 能否找到修复 bug 的提交,而不是引入 bug 的提交?

可以。使用 old 和 new 这两个术语来代替 good 和 bad 即可。先执行 git bisect start,用 git bisect new 标记一个已修复的提交,用 git bisect old 标记一个更早的、存在问题的提交,Git 就会报告首个 new 提交,也就是修复所在的提交。如需更直观的标签,可以执行 git bisect start --term-old broken --term-new fixed,然后使用 git bisect fixed 和 git bisect broken 来标记提交。

git bisect 如何处理来自功能分支的合并提交?

默认情况下,git bisect 也会测试已合并分支内部的提交,其中包括可能无法构建的半成品(work-in-progress)提交。使用 Git 2.29 新增的 git bisect start --first-parent,可以将搜索限定在历史主线上。在 main 分支上,它会停在引入 bug 的那次合并提交处,而不会测试该分支自身的提交。随后再对该分支进行一次 bisect,即可找到确切的提交。

如果正常提交不是有问题提交的祖先,会发生什么?

Git 会检出这两个提交的合并基(merge base),并要求你先对其进行测试。如果合并基是正常的,bisect 会照常在合并基与有问题提交之间的提交上继续进行。如果合并基本身就有问题,Git 会直接停止而不再搜索,因为这说明正常提交位于另一条独立的历史线上,在那条线上该 bug 并不存在或已被修复。

git bisect 和 git blame 有什么区别?

git blame 会逐行告诉你某个文件中每一行最近一次是被哪个提交修改的,因此它只能回答“谁最后改了这一行”。git bisect 则跨提交测试实际行为,因此即使问题源于依赖升级、配置变更,或者出现在与症状所在文件不同的其他文件中,它也能找到回归问题。建议先用 bisect 找到问题提交,再用 blame 追溯该提交所修改的代码行。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.