Git bisect回归定位实战:跳过坏构建,保留候选证据

昨天还能通过的边界测试,今天失败了。如果中间有一串提交,逐条阅读差异容易把“看起来可疑”当成原因。本文用一份可以下载运行的合成仓库,把同一个回归测试交给Git反复执行:定位错误首次出现的位置,并保留无法唯一定位时的候选范围。

适合已经会提交代码、需要排查回归的开发者。实验只创建临时仓库,不读取现有项目、不联网、不推送、不修改全局Git配置;本次实测macOS、Python 3.14.4、Git 2.50.1(Apple Git-155)。读者需要Python 3.11+和Git 2.28+,其他版本及平台没有在本轮验证。

下载完整实验包:七次提交、外置测试、定位日志与歧义对照(ZIP)

用七次提交制造一个明确的错误

解压进入bisect-lab-v1,运行python3 run_lab.py。脚本会做两次实验,最后输出JSON结果;临时仓库随运行结束清理。若需要保存本次证据,可把标准输出重定向到一个新的结果文件。

业务规则很具体:金额以分为单位,满5000分免运费,否则运费699分。测试同时检查4999、5000、5001三个值,避免只检查两端而漏掉等号。提交顺序如下,字母只是实验标签,实际提交ID每次生成可能不同:

A → B → C → D → E → F → G
正确  正确  正确  无法编译  正确  边界改错  保留错误

D故意缺少函数定义的右括号,不能执行;E修复了语法且业务仍正确。F把amount >= 5000改为amount > 5000,于是恰好5000分的订单多收699分。我们要找的是这项业务回归,不是历史上所有构建问题。

实测版本 4999分 5000分 5001分 测试退出码
A、E 699 0 0 0
D 语法错误,未进入业务判断 125
F、G 699 699 0 1

这是运行器在二分前实际执行的基线检查。只有已知好版本能通过、已知坏版本稳定失败,才值得把测试交给自动定位。若两个端点都失败,应先检查测试、依赖和输入,而不是让Git替你猜。

测试脚本如何回答“好、坏、无法判断”

包内的probe.py放在被检出的仓库之外,所以Git切换旧提交不会顺便把测试脚本换掉。它读取当前目录的合成shipping.py,先编译,再检查三个金额的结果。

expected = {4999: 699, 5000: 0, 5001: 0}
observed = {amount: shipping_cents(amount) for amount in expected}
exit_code = 0 if observed == expected else 1

上面只展示业务判定,完整文件还处理无法执行的版本与异常。按Git的退出码约定,0表示通过;1至127中除125外的值表示失败;125跳过本次提交;其他退出码终止定位。本包把确定的业务不匹配设为1,把语法错误或缺少实验文件设为125,把意外执行异常设为128,避免把不明原因直接归为这项边界回归。

这个分类必须跟着调查问题调整。如果你正在寻找“哪次提交把构建弄坏了”,语法错误本身就是坏结果,不应跳过。如果真实项目依赖服务器偶尔断线,也不能简单写成失败1;应先让环境稳定,或终止本轮。把所有异常吞掉并返回0会漏报,把所有异常都返回1则可能找出环境故障首次出现的位置。

另一个容易忽略的情况是命令根本没启动:shell的126、127仍可能落入Git判为坏版本的范围。因此在开始前独立运行测试解释器与脚本,确认它们存在、可执行、能识别端点。不要只检查git bisect run命令是否成功启动。

在另一工作目录定位,保留手头的修改

实验先在main的工作文件中加入一行未提交说明,再从G创建detached worktree。所有检出、测试和二分都发生在新目录。结束后,运行器逐字比较原目录文件、Git状态和main分支,确认未提交说明没有丢失。

实际仓库可以采用下面的顺序。三个变量必须换成已经核对的完整提交ID与外置测试路径;工作目录名必须尚不存在。此片段是迁移实验思路的模板,包内运行器才是直接可执行的练习:

good='已验证通过的提交ID'
bad='已验证失败的提交ID'
probe='/绝对路径/你的回归测试.py'
git worktree add --detach ../regression-check "$bad"
git -C ../regression-check checkout --detach "$good"
(cd ../regression-check && python3 -B "$probe")
# 确认上一步为0,再检出坏端点并确认返回预期失败码
git -C ../regression-check checkout --detach "$bad"
(cd ../regression-check && python3 -B "$probe")
git -C ../regression-check bisect start "$bad" "$good"
git -C ../regression-check bisect run python3 -B "$probe"

不要把本包专门检查运费的probe原样用于别的业务。真实测试应固定同一输入、依赖版本和构建方式,清理自己产生的构建缓存,保证历史切换不会读取上一个版本残留的产物。本实验直接编译内存中的Python源码,并禁用字节码写入,不依赖外部包或构建缓存。

worktree分开工作文件和索引,但共享仓库对象与部分引用,也不能隔离历史代码的副作用。运行真实旧版本的安装或构建脚本前,要评估它是否会联网、使用凭据或写到工作目录以外;必要时放进受控测试环境。本文没有对不可信代码提供沙箱。

找到F以后,再看它到底改了什么

第一组实测先遇到D,测试返回125,Git跳过它;后续验证了好版本并测试F,最终报告F是第一个坏提交。运行器同时保存git bisect log和完整命令输出,确认D确实进入过跳过记录,结论也确实指向预先埋入错误的F。

F与其父提交E的业务差异只有比较符:

-    return 0 if amount >= 5000 else 699
+    return 0 if amount > 5000 else 699

这个差异与5000分的失败结果直接对应;E返回0、F返回1的端点复核也保留在结果中。因此本例有足够证据把问题缩到这次变化。Git输出的提交ID只是定位结果,仍需要读差异并验证业务因果,不能把作者、提交标题或一段看起来奇怪的代码当作结论。

跳过关键提交时,结果只能是一组候选

第二组使用另一份同样的合成历史,故意让F也返回125。此时E是好版本,G是坏版本,但F无法测试。Git实际退出码为2,输出候选F和G,而没有给出唯一的首个坏提交。

场景 可用证据 实际结论
只跳过D E通过,F失败 唯一定位F
D、F都不可测试 E通过,G失败,F未知 F或G,无法唯一定位

运行器专门检查第二组没有出现唯一定位的输出,且候选中包含F与G。不要为了让自动化报告变绿,就把候选列表第一项写成“已找到原因”。可以尝试修复测试环境,使F能够接受同一判定;若必须给旧版本加兼容补丁,应记录补丁并证明它不改变正在测试的业务条件。否则就交付候选范围和缺失证据。

二分也依赖“选定范围内,这项性质从好转坏”的假设。某个错误引入后又被修复、随后再次出现,或测试本身随机失败,都需要先缩小调查区间、固定复现。此实验是线性历史,未验证合并提交、子模块、LFS、浅克隆或性能波动。

保存证据,再退出定位状态

本包无论出现唯一结果还是候选歧义,都会在finally中执行bisect reset,再检查定位目录回到原先的G、目录干净、main的未提交文件未变,最后用普通worktree remove移除练习目录,没有使用强制删除。

真实工作先把日志保存到定位目录之外,再退出:

git -C ../regression-check bisect log > bisect-log.txt
git -C ../regression-check bisect reset
git -C ../regression-check status --short
# 确认结果已保存且该目录不再需要后
git worktree remove ../regression-check

若移除被拒绝,先查看是否留下了有用文件,不要直接加force。若测试进程超时、手动中断或机器退出,重新打开目录先查状态,不能假设自动清理一定发生。本轮实际验证了正常完成与候选歧义两种退出路径,没有模拟断电或杀死整个运行器。

源码包采用MIT许可,SHA-256为85c68dc819e07aa65b74ab7a86a683d13f430eadef73d72a4ac10643db60ea55。包中的观察结果来自本次真实运行,仅把本机安装路径替换为占位文字。封面复用本站浅色版本分支与独立目录概念图,非终端截图;正文用实验表格和实际差异解释结果。

官方依据:git bisect退出码、跳过与日志、git worktree工作目录及共享范围。相关旧稿:从Git文件历史查修改线索、查看历史文件而不覆盖工作区、保留暂存状态并隔离stash冲突现场。

资料查阅与编辑日期:2026年11月3日。作者:玖柒。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享