git bisect 卡在 skipped commit 上

git bisect 反复 skip,最后只给出「The first bad commit could be any of...」一堆 SHA 而不是唯一结果。本文教你整段 skip、用退出码 125 自动跳过,并修掉构建/环境问题让 bisect 真正收敛。

你用 git bisect 追查一个回归 bug 是哪个 commit 引入的。二分逐渐收窄到某个区间后,发现里面每个候选 commit 都无法测试——可能是中途有一次大重构导致编译失败,可能是测试框架换了,也可能是当年的 lockfile 现在装不上。你只好反复 git bisect skip,最后 Git 直接放弃:

There are only 'skip'ped commits left to test.
The first bad commit could be any of:
abc1234...
def5678...
ghi9012...
We cannot bisect more!

这不是崩溃,而是 Git 在告诉你:可测试的 commit 用完了,没法把嫌疑范围收窄到唯一一个 SHA。

最快的修法(多数情况): 这些 commit 往往不是真的「无法测试」,只是「编译不过」。别再手动一个个回答,让 git bisect run 通过退出码 125 只把真正构建失败的 commit 标成 skip,对能编译的 commit 照常判断好坏:

git bisect run sh -c 'npm run build >/dev/null 2>&1 || exit 125; npm test -- -t "regression"'

如果整个区间确实编译不了(一次性大重构),就用 git bisect skip <good>..<bad-window-end> 一次跳过整段,再对剩下的线性历史做二分。下面教你怎么判断自己属于哪种情况,以及怎么把 bisect 救回来。

先判断你属于哪种情况

先跑这三条命令,它们能直接告诉你该用哪种修法。

检查项命令含义
skip 了多少次?git bisect log | grep -c skip卡住信息前有一长串 skip = 存在构建坏掉的区间
good/bad 反了吗?git log --oneline <good>..<bad>输出为空 = 你的 goodbad 更新,范围方向反了
测试 flaky 吗?对同一个被 skip 的 commit 跑 3 次测试每次结果不一样 = flaky 测试被误判成「无法测试」

常见原因

按命中率从高到低排列。

1. 一次性大重构留下了一段构建坏掉的区间

重构作为一个(或几个)commit 落地,中途无法编译,于是范围中间有 5 到 15 个 commit 全都构建失败,每个候选都变成 skip。

怎么判断git bisect log 在卡住信息前有一长串 skip,且 git log --oneline <good>..<bad> 里在这段「死区」附近能看到一个巨大的「refactor」commit。

2. 当年的 lockfile 或工具链现在装不上了

这是最常见的「假 skip」。commit 在它自己那个年代能正常构建,但 package-lock.json / Cargo.lock / go.sum 锁定的版本,今天的 Node、Python 或编译器装不上。构建失败是环境问题,不是代码问题,于是你 skip 掉了一个本来可以测试的 commit。

怎么判断:安装/构建报错提到的是 peer-dependency 冲突、engine 版本不匹配、registry 上已下架的包,或语言版本不支持——而不是你自己的代码。把 node --version(或 python --version 等)和该 commit 落地时项目用的版本对比一下;像 Node 18 跳到 22 就是典型诱因。

3. 区间中途换了测试框架

bisect 区间正好跨过一次测试框架迁移(比如 Mocha 换成 Vitest,或 Jest 配置改成 ESM)。你传给 bisect run 的测试命令在老 commit 上根本不存在,脚本直接退出,于是该 commit 被 skip。

怎么判断git show <older-candidate>:package.json 显示 scripts.test 不一样(或缺失)。测试步骤报 command not found(退出码 127)就是信号。

4. 测试 flaky,随机返回 125

自动化模式下,一个 flaky 测试偶尔启动失败,会让 git bisect run 把完全可测试的 commit 标成 skip。这些假 skip 在真正的 bug commit 周围越积越多,于是产生「could be any of」列表。

怎么判断:手动 checkout 一个被 skip 的 commit,把测试连跑三次。结果不一致,说明问题在测试本身,不在 commit。

5. 回归是 merge commit 引入的

区间里有 merge commit,而 bug 恰恰是这次合并的冲突解决引入的。bisect 能停在这个 merge 上,但没法测试一个有意义的中间状态。

怎么判断git bisect log | grep -i merge,或 git log --graph --oneline <good>..<bad> 里 merge 集中在被 skip 的区域。

6. good 和 bad 标反了(或历史非线性)

如果你把有 bug 的 commit 标成了 good(或 old/new 顺序反了),bisect 会朝错误方向搜索,碰到的全是「无关」commit,你只能一路 skip 进死胡同。

怎么判断git log --oneline <good>..<bad> 没有任何输出——这说明 badgood 的祖先,范围为空或方向反了。

最短修复路径

Step 1:动手之前先看清当前 bisect 状态

git bisect log                  # 完整会话:到目前为止每一步 good/bad/skip
git bisect log > /tmp/bisect-backup.log   # 存下来——reset 会清掉它
git bisect visualize --oneline  # 剩余的嫌疑 commit(别名 git bisect view)

git bisect visualize 在检测到图形会话时(通过 DISPLAYSESSIONNAMEMSYSTEM 或 macOS 的 SECURITYSESSIONID)会打开 gitk,否则退回到 git log。加上 --oneline--stat 可强制走纯文本视图。数清剩下的 commit 里有多少是「真的」无法测试,多少是「假 skip」。

Step 2:整段 skip,而不是一个一个跳

如果某段连续区间确实无法测试,一次性跳过它。范围记法 <start>..<end>起点不含、终点含

# 跳过 good_sha 之后、直到并包含 bad_window_end 的所有 commit:
git bisect skip good_sha..bad_window_end

如果连区间起点的那个 commit 也要跳,单独列出来:

git bisect skip good_sha good_sha..bad_window_end

或者跳过指定的单个 SHA:

git bisect skip abc1234 def5678 ghi9012

Step 3:让 bisect run 自动标记无法测试的 commit

手动逐一回答又慢、又是产生误 skip 的根源。写一个脚本,返回 Git 期望的精确退出码:

  • 0 = good / old(不含回归)
  • 1124,以及 126127 = bad / new(含回归)
  • 125 = 无法测试,跳过此 commit
  • 其他任何值(尤其 >= 128)= 中止整个 bisect

125 之所以是「跳过」的魔术码,是因为 POSIX shell 占用了 126(找到但不可执行)和 127(命令未找到)。

#!/bin/sh
# bisect-test.sh —— 退出 125 表示「这里没法测,跳过」
# 先构建;构建失败属于「无法测试」,不是「bad」:
npm run build >/dev/null 2>&1 || exit 125

# 只跑针对这个回归的那一个测试:
npm test -- --testNamePattern="performance regression" >/dev/null 2>&1
code=$?
[ "$code" -eq 127 ] && exit 125   # 老 commit 里测试运行器缺失 = skip
exit "$code"                       # 0 = good,非 0 = bad
chmod +x bisect-test.sh
git bisect run ./bisect-test.sh

底层上,退出 125 会替你调用 git bisect skip,所以一次干净的 run 永远不会去 skip 一个其实能构建、能测试的 commit。

Step 4:从源头修掉「假 skip」

如果 skip 来自环境(原因 2)而非代码,就恢复一个兼容的工具链,让那些 commit 重新变得可测试——往往这一步就能把嫌疑列表收窄到唯一一个 SHA:

# 切到该 commit 当年项目使用的 Node 版本:
nvm install 18 && nvm use 18
rm -rf node_modules
npm ci || npm install
npm test

把同样的步骤固定进 bisect-test.sh(比如 nvm use 18,或在固定的 Docker 镜像里构建),让每次 checkout 都用「那个年代正确的」工具链。对于 flaky 测试(原因 4),在脚本里下结论之前先重试,别让一次坏运行就把 commit skip 掉:

# 在 bisect-test.sh 里:3 次里只要有 1 次通过就算 good
for i in 1 2 3; do
  npm test -- --testNamePattern="regression" >/dev/null 2>&1 && exit 0
done
exit 1

Step 5:good/bad 不贴切时改用自定义 term

对于「功能能用但变慢了」这种性能回归,good/bad 反而容易混淆。用你自己的术语:

git bisect start --term-old fast --term-new slow
git bisect fast <known-fast-sha>
git bisect slow HEAD
git bisect terms   # 随时确认哪个 term 对应 old、哪个对应 new

Step 6:Git 仍然返回一个范围时,逐个人工排查

如果嫌疑 commit 确实无法测试、列表就是收不窄,那就逐一检查:

for sha in abc1234 def5678 ghi9012; do
  echo "=== $sha ==="
  git show --stat "$sha"
done

找那个改动了行为关键代码的 commit——往往是一处配置变更、一次依赖升级,或一个细微的逻辑取反。如果想在修正了早先的错误标记后重跑搜索,编辑保存的 log 再回放,而不是从头来:

git bisect reset
# 编辑 /tmp/bisect-backup.log,删掉那条标错的 good/bad/skip
git bisect replay /tmp/bisect-backup.log

Step 7:收尾清理

git bisect reset   # 把 HEAD 还原到你开始 bisect 前所在的分支

如何确认已经修好

出现以下情况,说明你拿到的是真正的结果,而不是又一个死胡同:

  • git bisect run(或你最后一次手动回答)打印出单独一行、以 is the first bad commit 结尾,后面跟着该 commit 的 git show 摘要——而不是「could be any of」列表。
  • git bisect log 以一条 bad 结论紧挨着一条 good 结论收尾(两者是父子关系),中间没有 skip。
  • checkout 到报告出的 commit 跑测试能复现回归,而它的父 commit(<sha>~1)不会:
git checkout <first-bad-sha>~1 && npm test   # 应该通过
git checkout <first-bad-sha>   && npm test    # 应该失败

预防建议

  • 保持 main 上每个 commit 都能构建。每次合并都过 build check,将来做 bisect 就不会撞上「整段都得 skip」的窗口。哪怕是 feature 分支上的 WIP commit,也应该能编译。
  • bisect-test.sh 加退出码 125 来驱动 bisect,而不是手动回答——更快,也绝不会误 skip 掉一个可测试的 commit。
  • 按 commit 固定工具链。在固定的 Docker 镜像里构建,或在脚本里读取 .nvmrc,让历史 lockfile 仍能安装。
  • 给已知良好的版本打 tag:git tag good/v2.3.0,然后 git bisect start HEAD good/v2.3.0 就能用最小的范围起步。
  • 别把大重构塞进一个 commit。拆成相互独立、各自可构建的小步。
  • 找到罪魁后保存 log(git bisect log > bisect-finding.txt),方便队友回放复盘。

常见问答 (FAQ)

Q:git bisect run 的退出码到底各是什么意思? A:0 = good/old(不含回归),1124 以及 126127 = bad/new(含回归),125 = 无法测试(跳过此 commit),其他任何值(包括信号导致的 128+)会中止 bisect。脚本里绝对不要用 125 表示「bad」——那样它会被悄悄当成 skip。

Q:Git 返回了一个范围而不是单个 SHA,这是 bug 吗? A:不是。当只剩 skip 过的 commit 时,Git 无法证明哪个才是第一个 bad,于是列出所有候选并说「We cannot bisect more!」。把这些 commit 变得可测试(修构建/工具链)后重跑,或者逐个人工排查。

Q:git bisect skip a..b 两端都包含吗? A:不。范围是起点不含、终点含,所以 a..b 跳过 a 之后、直到并包含 b 的所有 commit,但不含 a 本身。要连 a 也跳,运行 git bisect skip a a..b

Q:first bad commit 是个 squash merge / 依赖升级,怎么找到真正原因? A:对于 squash commit,bisect 只能定位到这个 squash;可以对 squash 之前那条 feature 分支历史再做一次 bisect,或者手动审查它的 diff。对于依赖升级,去查那个依赖在两个版本之间的 changelog,并通过重装旧版本(npm install <dep>@<old-version>)重跑失败测试来确认。

Q:能跨多个分支或跨 merge 做 bisect 吗? A:git bisect 只在单一可达历史上行走。用 git log --graph --oneline <good>..<bad> 看清 merge 点;如果 bug 在某次 merge 的冲突解决里,就单独对被合并进来的那条分支做 bisect。如果测试需要 submodule 处于匹配的 commit,在脚本里加上 git submodule update --init --recursive

Q:做到一半把 good/bad 标错了,必须从头开始吗? A:不用。用 git bisect log > log.txt 存下会话,删掉标错的那行,然后 git bisect resetgit bisect replay log.txt。这也是为什么任何 reset 之前都该先备份 log——reset 会丢掉进行中的会话。

相关阅读

标签: #git #version-control #排查