Git:用 bisect run 自动定位首次引入回归的提交

111 次浏览5 条回复

当回归夹在一串提交中时,逐个 checkout 验证会很慢。git bisect 用二分查找缩小范围,git bisect run 还能反复执行同一个判定脚本,自动找出首个失败提交。

环境:Git 2.39+;Linux 或 macOS;Bash;示例还使用 coreutils 的 cut。下面只操作新建的临时仓库。

set -Eeuo pipefail

root=$(mktemp -d)
repo="$root/demo"

git init -b main "$repo"
git -C "$repo" config user.name "Bisect Demo"
git -C "$repo" config user.email "[email protected]"

# 前三个提交正常,从 limit=4 开始视为回归。
for value in 1 2 3 4 5; do
  printf 'limit=%s\n' "$value" >"$repo/app.conf"
  git -C "$repo" add app.conf
  git -C "$repo" commit -m "set limit to $value"
done

cat >"$repo/check.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
value=$(cut -d= -f2 app.conf)
test "$value" -lt 4
EOF
chmod +x "$repo/check.sh"

# HEAD 已知失败,HEAD~4 已知正常。
git -C "$repo" bisect start HEAD HEAD~4
git -C "$repo" bisect run ./check.sh

# 此时 HEAD 停在首个失败提交,应显示 set limit to 4。
git -C "$repo" show -s --format='%h %s' HEAD
git -C "$repo" bisect reset
rm -rf -- "$root"

判定脚本返回 0 代表正常,返回 1 到 127(125 除外)代表失败;当前提交无法可靠测试时可返回 125,Git 会跳过它。脚本应尽量确定、快速,并避免依赖波动的网络服务,否则一次偶发失败就可能把搜索带到错误区间。

实际仓库中,边界也可以直接写标签或提交哈希,例如 git bisect start v2.4.1 v2.4.0。无论成功还是中断,最后都应执行 git bisect reset,恢复开始二分前的分支与工作区位置。

补一个真实仓库里很重要的收尾细节:示例末尾直接调用 git bisect reset,但如果判定脚本异常退出,或执行过程中收到 Ctrl-C,在 set -e 下可能走不到这一行。可以让 EXIT trap 统一恢复二分前的位置,同时保留原退出码。

环境:Bash 4.4+,Git 2.39+。

#!/usr/bin/env bash
set -Eeuo pipefail

bisect_active=0
cleanup() {
  status=$?
  trap - EXIT
  if (( bisect_active )); then
    git bisect reset >/dev/null 2>&1 || true
  fi
  exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

git bisect start "$bad_commit" "$good_commit"
bisect_active=1
git bisect run ./check.sh

这里不要在主流程里再次 reset;让 trap 只负责一次收尾即可。若 git bisect start 本身失败,标记尚未置为 1,也不会误改仓库状态。

clearharborLv1#1

补一个真实仓库里很重要的收尾细节:示例末尾直接调用 git bisect reset,但如果判定脚本异常退出,或执行过程中收到 Ctrl-C,在 set -e 下可能走不到这一行。可以让 EXIT trap 统一恢复二分前的位置,同时保留原退出码。

环境:Bash 4.4+,Git 2.39+。

#!/usr/bin/env bash
set -Eeuo pipefail

bisect_active=0
cleanup() {
  status=$?
  trap - EXIT
  if (( bisect_active )); then
    git bisect reset >/dev/null 2>&1 || true
  fi
  exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

git bisect start "$bad_commit" "$good_commit"
bisect_active=1
git bisect run ./check.sh

这里不要在主流程里再次 reset;让 trap 只负责一次收尾即可。若 git bisect start 本身失败,标记尚未置为 1,也不会误改仓库状态。

这套收尾能避免仓库停在二分状态。再补一个与 trap 配套的细节:判定脚本最好显式区分“回归成立”和“当前提交无法判定”。git bisect run 会把除 125 外的普通非零码视为 bad;测试框架的配置错误、收集失败若直接透传,可能被误当成回归。

以 pytest 为例,下面的模板把测试通过映射为 0、测试失败映射为 1,把其余 pytest 状态映射为 125;中断信号仍用大于 128 的状态让二分立即停止。

环境:Git 2.39+;Bash 4.4+;Python 3.11+;pytest 8.x;仓库已有 tests/test_regression.py。

#!/usr/bin/env bash
set -u

trap 'exit 130' INT
trap 'exit 143' TERM

set +e
python -m pytest -q tests/test_regression.py
status=$?
set -e

case "$status" in
  0) exit 0 ;;   # good
  1) exit 1 ;;   # bad:测试断言失败
  *) exit 125 ;; # skip:无法可靠判定
esac

这里的映射要按所用测试器的退出码契约调整,不能机械套用。125 也不宜滥用:若首个坏提交附近连续很多版本都被跳过,Git 最后可能只能给出一组候选提交,而无法唯一定位。

还可以把二分过程保存下来,便于复核或交给同事检查。git bisect log 记录的是边界和每次 good/bad/skip 标记;git bisect replay 可在包含相同提交的仓库中恢复这些标记。

环境:Git 2.39+;Linux 或 macOS;Bash;工作区应先保持干净。

log_file=../bisect.log

git bisect start "$bad_commit" "$good_commit"
git bisect run ./check.sh
git bisect log >"$log_file"
git bisect reset

# 复核同一次二分结论,不重新执行判定脚本。
git bisect replay "$log_file"
git show -s --format='%h %s' HEAD
git bisect reset

这个日志不包含测试输出,也不能证明依赖环境一致;若结论需要长期留档,最好同时保存判定脚本、关键工具版本和失败输出。日志里使用的是提交对象,因此换到另一个克隆复核前,还要确认相关提交没有因浅克隆而缺失。

如果判定逻辑只需读取提交对象,不必实际构建或执行该版本,可以用 --no-checkout 避免二分期间反复改写工作区。此模式每一步只更新 BISECT_HEAD 引用,判定脚本需要显式从对象库读取文件。

环境:Git 2.39+;Bash 4.4+;示例仓库的 app.conf 内容形如 limit=4。

创建 check-object.sh:

#!/usr/bin/env bash
set -u

content=$(git show BISECT_HEAD:app.conf 2>/dev/null) || exit 125
value=${content#limit=}

if [[ $content != limit=* || -z $value || $value == *[!0-9]* ]]; then
  exit 125
fi

if (( value < 4 )); then
  exit 0
fi
exit 1

执行二分:

chmod +x check-object.sh
git bisect start --no-checkout "$bad_commit" "$good_commit"
git bisect run ./check-object.sh
git show -s --format='%h %s' BISECT_HEAD
git bisect reset

这种方式适合检查配置、生成文件或静态规则;依赖工作区文件、编译产物或历史版本可执行程序的测试不适用,因为 --no-checkout 不会把目标提交展开到工作区。文件缺失或格式无法解析时返回 125,仍需留意连续跳过过多提交会降低定位精度。

合并提交较多的主干还可以先做一次 --first-parent 二分。它只沿第一父链搜索,适合先回答“哪一次合并把回归带进主干”,避免第一轮就进入被合并分支的内部历史。

环境:Git 2.39+;Linux、macOS 或 Windows;工作区保持干净;沿用主帖的 check.sh。

bad_commit=HEAD
good_commit=v2.4.0

git bisect start --first-parent "$bad_commit" "$good_commit"
git bisect run ./check.sh
git show -s --format='%h %p %s' HEAD
git bisect reset

若结果是一个合并提交,并且还要检查被合并分支内部,可再用该合并的第一父提交作为 good 边界做普通二分:

merge_commit=<上一步得到的提交哈希>
git bisect start "$merge_commit" "${merge_commit}^1"
git bisect run ./check.sh
git bisect reset

第二轮测试的是分支提交在其原始历史中的状态,未必复现合并时的冲突解决或与主干组合后的行为。如果只有合并结果失败、分支内部提交都通过,应重点检查合并提交本身,而不是强行把问题归到某个分支提交。