用 git bisect run 自动定位首个引入回归的提交

139 次浏览4 条回复

当一段较长的提交历史里出现回归,并且已有命令能判断当前版本是否正常时,git bisect run 可以把二分查找自动化。测试脚本的退出码就是判定协议:0 表示正常,1 到 127(除 125)表示异常,125 表示当前提交无法测试;其他退出码会中止本次查找。

环境:Git 2.39+、Bash;Linux 或 macOS。下面的示例只在新建的临时目录中运行,不会改动现有仓库。

先构造一段包含回归的历史,并给已知正常、已知异常的两端打标签:

tmp="$(mktemp -d)"
cd "$tmp"
git init -b main
git config user.name "Bisect Demo"
git config user.email "[email protected]"

printf 'mode=fast\n' > app.conf
git add app.conf
git commit -m 'good: use fast mode'

printf 'note=baseline\n' > notes.txt
git add notes.txt
git commit -m 'docs: add baseline note'
git tag known-good

printf 'mode=slow\n' > app.conf
git commit -am 'refactor: change processing mode'

printf 'note=follow-up\n' >> notes.txt
git commit -am 'docs: add follow-up note'

printf 'note=release\n' >> notes.txt
git commit -am 'docs: add release note'
git tag known-bad

再创建判定脚本。这里把 mode=fast 当作正常状态,grep 的退出码恰好符合 bisect 的判定协议:

cat > check.sh <<'EOF'
#!/usr/bin/env bash
grep -qx 'mode=fast' app.conf
EOF
chmod +x check.sh

git bisect start known-bad known-good
git bisect run ./check.sh
git bisect reset

运行过程中 Git 会反复检出中间提交并执行脚本,最后报告 first bad commit;示例中会定位到修改 app.conf 的提交。git bisect reset 会恢复开始二分前检出的分支。

在真实项目中,可以把 ./check.sh 换成单元测试、编译检查或最小复现脚本。需要特别区分三种结果:功能断言失败应返回普通非零值;提交因为缺少生成物等原因无法判断时返回 125;网络中断、测试环境损坏等基础设施故障不应伪装成回归,可以返回 128 让查找停止。开始前也应保持工作区干净,避免反复切换提交时混入本地修改。

再补一个适合真实仓库的细节:最好把 bisect 的判定脚本放在工作树之外。这样脚本不会让 git status 出现未跟踪文件,也不会因为某个历史提交恰好存在同名路径而被覆盖。

环境:Git 2.39+、Bash;Linux 或 macOS。在仓库根目录执行:

driver="$(mktemp)"
cat >"$driver" <<'EOF'
#!/usr/bin/env bash
set -u
grep -qx 'mode=fast' app.conf
EOF
chmod +x "$driver"

repo="$(git rev-parse --show-toplevel)"
cleanup() {
  git -C "$repo" bisect reset >/dev/null 2>&1 || true
  rm -f "$driver"
}
trap cleanup EXIT INT TERM

git bisect start known-bad known-good
git bisect run "$driver"

trap 还能覆盖脚本中断的情况,避免仓库停留在 bisect 状态。判定脚本里不建议直接启用 set -e:如果要把“测试失败”“当前提交不可测”和“基础设施异常”分别映射为 1、125、128,显式捕获命令退出码会更清楚。对于需要编译的项目,还应清理或按提交隔离构建产物,避免旧产物影响二分结果。

合并提交较多的仓库还需要先决定要定位的是“主线上的哪次合并”,还是分支内部的具体提交。若第一阶段只想沿主线排查,可以使用 --first-parent,减少进入已合并分支后遇到不可构建中间状态的概率。

环境:Git 2.39+、Bash;known-good 必须是 known-bad 在第一父链上的祖先。可直接接在楼主的示例仓库中运行:

git bisect start --first-parent known-bad known-good
git bisect run ./check.sh
git bisect log >"$tmp/bisect-first-parent.log"
git bisect reset

这种模式报告的首个异常提交可能是一个 merge commit,含义是“回归首次进入主线的位置”,不一定是分支里真正写入问题代码的提交。需要继续追根时,可以查看该合并提交的父提交,再对相应分支做第二轮普通 bisect。git bisect log 则保留了起点和每次判定,便于核对是否因大量 skip 或不稳定测试得到含糊结果;同一份提交历史中也可以用 git bisect replay <日志文件> 重放。

如果已经能确定回归只可能由某组路径引入,还可以用 pathspec 缩小候选提交集合。这样文档或其他无关目录的提交不会参与二分,长历史中能少跑几轮判定。

环境:Git 2.39+、Bash;以下命令可直接用于楼主的示例仓库:

git bisect start known-bad known-good -- app.conf
git bisect run ./check.sh
git bisect reset

这里 -- 用来结束修订参数,后面的 app.conf 是路径限制;示例中的两次 notes.txt 提交会被排除。也可以指定多个路径,例如 -- src/ package.json package-lock.json。

这个优化只适合边界明确的情况:构建脚本、依赖锁文件、生成器配置或跨目录接口都可能间接造成回归。若不能确认影响范围,保持全历史二分更可靠;也可以先做一次完整二分,再用路径限制验证结果。

还有一个容易让自动二分得出错误结论的前提:判定脚本必须稳定。同一提交偶尔通过、偶尔失败时,单次执行会把随机结果当成事实。可以让包装脚本重复判定:全部通过才返回 0,全部失败才返回 1,结果混合则返回 125 跳过该提交。

环境:Git 2.39+、Bash;以下脚本可直接用于楼主的示例仓库,其中 grep 返回 0 表示通过、1 表示断言失败、其他值表示判定器自身异常。

cat > check-stable.sh <<'EOF'
#!/usr/bin/env bash
set -u

passes=0
failures=0
for attempt in 1 2 3; do
  grep -qx 'mode=fast' app.conf
  status=$?

  case "$status" in
    0) ((passes += 1)) ;;
    1) ((failures += 1)) ;;
    *) exit 128 ;;
  esac
done

if ((passes == 3)); then
  exit 0
elif ((failures == 3)); then
  exit 1
else
  exit 125
fi
EOF
chmod +x check-stable.sh

git bisect start known-bad known-good
git bisect run ./check-stable.sh
git bisect reset

接入真实测试命令时,应先约定哪些退出码代表功能失败,哪些代表测试环境异常,不要把所有非零值都计入 failures。重复执行只能降低偶发误判,不能修复不稳定测试;如果出现大量 125,Git 可能只能给出一组可疑提交,此时应先固定随机种子、隔离共享状态或修复时序依赖,再重新二分。