你的 CI 在 main 上红了,但昨天还是绿的。在那之后到现在之间的某个时刻,一个 bug 溜了进来。你可以滚动浏览 200 个 commit,阅读 diff 并猜测。你可以在 Slack 里问,指望有人记得碰过相关代码。或者你可以让 Git 来干活。
git bisect 是一个针对你 commit 历史的二分查找工具。你标记一个 commit 为 bad,一个为 good。Git checkout 中点。你测试它,标记 good 或 bad,Git 重复。8 步之内,它可以从 256 个 commit 中隔离出一个。12 步之内,它可以在 4096 个中找到那根针。这是 Git 最接近历史调试器的东西。
为什么手动二分查找是在浪费你的时间
开发者已经在不自觉地手动做二分查找了。你 checkout 一个旧 commit,运行测试,心想”还是坏的,得再往前。“然后你 checkout 一个更旧的,测试通过了。现在你知道 bug 在这两个点之间。于是你挑一个中间的,继续缩小范围。
那就是二分查找。git bisect 自动化了记账工作,让你不会丢失已经测过哪些 commit 的追踪。更重要的是,它防止了那种懒惰的启发式:“大概是周二那次大 refactor 干的”,这种直觉会让你在错误的方向上钻两个小时兔子洞。
真正的价值不在于速度,虽然它确实更快。真正的价值在于正确性。当你沮丧地在下午 6 点 hunt 一个 bug 时,你会犯错。你会忘记在 checkout 后重新构建。你会对同一个 commit 测两次。你会误读测试结果,把一个 bad commit 标成 good,毁掉你的搜索空间。git bisect 强制执行你在生气时没有的那份纪律。
二分查找实际如何工作
Git 不是按时间顺序搜索。它是按拓扑结构搜索,遍历 commit graph 来寻找已知 good 和已知 bad commit 之间的中点。这很重要,因为当你的历史中有 merge 时,时间上的中点可能根本不可达。
以下是幕后发生的事。你启动 bisect 并提供边界:
git bisect start
git bisect bad HEAD # current commit is broken
git bisect good v2.1.0 # this release was fine
Git 计算两点之间的 commit 数量。它 checkout 正中间的那个,等待你测试。你运行复现脚本,看看 bug 是否存在,然后告诉 Git:
git bisect bad # this commit has the bug
git bisect good # this commit is clean
Git 丢弃一半的搜索空间并重复。当只剩一个 commit 时,它停止并显示第一个 bad commit。输出包含 commit hash、作者、日期和消息。没有歧义。就是这个 commit 引入了 bug,到此为止。
一个带真实命令的具体走查
假设你的集成测试今早开始失败。你知道它们在最后一个带标签的 release v1.4.0 时通过了。以下是完整会话:
# Start the session
git bisect start
# Mark the current HEAD as bad
git bisect bad HEAD
# Mark the last known good release
git bisect good v1.4.0
# Git checks out a midpoint commit automatically
# You run your reproduction script or test suite:
npm test -- --grep "checkout flow"
# Tests fail. Mark it bad.
git bisect bad
# Git checks out another midpoint. Run tests again.
npm test -- --grep "checkout flow"
# Tests pass. Mark it good.
git bisect good
# Repeat until Git tells you:
# "<commit-hash> is the first bad commit"
最后,Git 把你留在 bad commit 上。你可以用 git show 检查它,创建修复,然后清理:
git bisect reset
这会把你恢复到开始前的分支。如果你忘记 reset,你会停留在一个 detached HEAD 上,然后纳闷为什么你的下一个 commit 不在你的分支上。我干过这事。很尴尬。
用脚本自动化整个过程
手动版本仍然要求你运行测试并反复输入 good 或 bad。如果你的复现是一个以 0 退出表示成功、非零退出表示失败的单一命令,你可以把整个事情交给 Git:
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# Hand over control to an automated script
git bisect run npm test -- --grep "checkout flow"
Git 会 checkout commit,运行你的命令,并自动分类结果。当它结束时,你得到同样的”第一个 bad commit”输出,而无需碰键盘。这就是 git bisect 从有用变成不可或缺的地方。
你的脚本不需要是一个测试套件。它可以是任何返回有意义退出码的可执行文件。以下是一个最小的 shell 脚本,检查特定的日志消息:
#!/bin/bash
# reproduce-bug.sh
# Exit 0 if the bug is NOT present (good)
# Exit 1 if the bug IS present (bad)
if curl -s http://localhost:3000/api/health | grep -q "database_timeout"; then
exit 1 # bug is present
fi
exit 0 # bug is not present
运行它:
chmod +x reproduce-bug.sh
git bisect run ./reproduce-bug.sh
退出码约定是严格的。退出 0 表示”good”,退出 1 到 124 表示”bad”,退出 125 表示”跳过这个 commit,它不可测。” 退出 125 在一个 commit 无法编译,或服务器因为无关的配置变更无法启动时很有用。Git 会跳过那个 commit 并在它周围搜索。
Bisect 失效的地方:那些陷阱
git bisect 假设你的 bug 是单调的。一旦某个 commit 引入了它,每个后代 commit 也都是 bad 的。如果 bug 闪烁不定,在不同 commit 之间时隐时现,二分查找就会崩溃。你会得到无意义的结果,或者 Git 会抱怨它无法隔离单个 commit。
非确定性 bug 是最糟糕的罪犯。一个 10% 时间失败的竞态条件偶尔会错误地把 bad commit 标成 good,破坏搜索。如果你的复现是 flaky 的,先修复不稳定性。或者在脚本中多次运行测试,只有当每次运行都通过时才标记 commit 为 good。这更慢但更可靠。
构建产物是另一个陷阱。如果你从一个改变了构建工具版本的 commit 切换到一个没改的 commit,你陈旧的 node_modules 或编译后的二进制文件可能与 checkout 的代码不匹配。在你的复现脚本中始终清理并重新构建,如果你的项目有构建步骤的话。
#!/bin/bash
# safer-reproduce.sh
rm -rf node_modules dist
npm ci
npm run build
npm test -- --grep "checkout flow"
这每次迭代增加几秒钟,但它消除了一整类误报——bug 实际上存在于陈旧产物中。
Bisect 不能做什么
git bisect 找到 commit。它不告诉你为什么那个 commit 是坏的。一个触及四十个文件的 5000 行 refactor 可能是第一个 bad commit,你仍然需要阅读 diff 来理解哪一行是罪魁祸首。Bisect 把你的范围从”过去一个月里的某个地方”缩小到”这个 diff 里的某个地方”。剩下的仍然是你的工作。
它也无法帮助那些存在于 codebase 中但从未被测试捕获的 bug。如果你的测试套件在 bug 被引入时已经是绿的,bisect 救不了你。你首先需要一条复现。Bisect 是搜索工具,不是检测工具。
什么时候用 bisect,而不是 blame 或 log
git blame 在你已经知道哪个文件包含 bug 时很棒。git log --grep 在你记得 commit message 的某些内容时很棒。Bisect 适用于你两者都不知道的情况。你只是知道它在 A 点工作,在 B 点失败,而它们之间的空间太大,无法推理。
如果你的团队在每个 commit 上都运行 CI,你有时可以通过阅读 CI 历史更快地 bisect。但 CI 只捕获它测试的东西。性能回归、视觉 bug 或你的测试遗漏的微妙逻辑错误不会出现在 CI 日志中。Bisect 适用于任何你能脚本化的复现,不管你的 CI 验证什么。
如何让 bisect 成为你工作流的一部分
你不需要特殊设置。这些命令是 Git 内置的。但有两个习惯能让你在真正需要 bisect 时更顺畅。
第一,给你的 release 打标签。Bisect 需要一个已知 good 的 commit,而标签是提供它的最简单方式。如果你的上一个 release 是三周前且你知道它是干净的,git bisect good v1.4.0 就是一行锚点。
第二,保持你的复现最小化。一个运行三十秒的复现对于八次迭代来说还可以忍受。一个运行十分钟的复现则是折磨。在你 bisect 之前,花五分钟把复现缩小到最小的可能命令。你未来的自己会感谢你。
完成后,立即运行 git bisect reset。一个带有未提交更改的 detached HEAD 是丢失工作的好方法。我通过惨痛经历学到了这一点,所以你不必。