Git bisect 从判定器到首个坏提交
差异审查解释一次提交保存了什么。另一个常见问题是:现在测试失败,一个较早版本能通过,究竟哪次提交引入了这个变化?逐个往回试可以找到答案,却可能重复运行许多次昂贵的测试。
git bisect 能减少要测的版本数,但它不会替你定义“坏”。真正决定结论是否可信的是判定器:它是否在每个版本上测量同一行为,能否把目标失败与环境故障分开?
同一输入、同一预期,确认两个端点。
保持左端通过、右端失败的不变量。
跳过意味着未知,定位之后仍要解释因果。
先说清楚什么才算坏
把输入固定为 20,程序读取版本中的 denominator,预期整数除法结果为 2。c0…c5 的分母为 10,c6…c8 改为 0,触发目标失败。端点 c0 通过、c8 失败,且只有一次从通过到失败的转变。
这里测试的是一个很小的固定性质,不是“整个版本没有任何错误”。如果老版本依赖无法安装、测试文件根本不存在,不能直接说它引入了这个除零问题。
根据 Git 官方 bisect 文档,自动判定的退出状态需要明确区分:
| 判定器退出码 | bisect 的解释 | 本文约定 |
|---|---|---|
| 0 | 好 | 固定输入得到预期结果 |
| 1 | 坏 | 目标性质不满足,包括除零 |
| 125 | 跳过 | 明确指定该快照不可测试 |
| 128 | 中止 | Git 读取、JSON 格式或测试基础设施异常 |
Git 还会把 1…127 中除 125 外的状态解释为坏,包括命令不存在常见的 127。因此不能不加检查就把任意构建命令当作可靠判定器。本文主动把基础设施异常转成 128;Git 的其他状态规则见上述手册。
为什么线性历史可以二分
假设提交按祖先顺序编号 c0…cN,存在唯一阈值 b:i<b 时好,i≥b 时坏。已知左端 L 好、右端 R 坏,则 b∈(L,R]。测试中点 M:好则令 L=M;坏则令 R=M。两种更新都保留阈值,并把候选数缩小到原来的约一半。
当 R=L+1,只剩 cR。若初始有 N 个可能引入点,理想中点策略最多需要 ⌈log₂N⌉ 次内部判定;端点预检另计。一次测试耗时 t 时,这部分成本约 O(t log N)。这只是线性、无跳过模型的推导,不是所有 Git 提交图的精确调用次数保证。
flowchart TD
A[确认左端通过 右端失败] --> B[选择中间候选]
B --> C{固定判定器结果}
C -- 通过 --> D[左端移到候选]
C -- 目标失败 --> E[右端移到候选]
C -- 不可测试 --> F[标记未知 尝试其他候选]
C -- 环境异常 --> G[中止 修复判定器]
D --> H{候选是否唯一}
E --> H
F --> H
H -- 否 --> B
H -- 是 --> I[核验候选与父版本的差异]
九个版本的手算
候选引入点最初为 c1…c8。下表是平衡中点的一条合法路径;遇到不同提交图或跳过点,Git 可以选择不同路径。
| 轮次 | 已知好端 | 已知坏端 | 测试版本 | 结果 | 剩余引入点 |
|---|---|---|---|---|---|
| 开始 | c0 | c8 | 端点已确认 | — | c1…c8 |
| 1 | c0 | c8 | c4 | 通过 | c5…c8 |
| 2 | c4 | c8 | c6 | 失败 | c5、c6 |
| 3 | c4 | c6 | c5 | 通过 | c6 |
若 c6 不能测试,而 c5 好、c7 坏,仅凭这些观测无法区分“c6 首坏”和“c7 首坏”。把 c6 猜成好会错误排除真实引入点;把它猜成坏又可能过早下结论。跳过的含义是缺失信息,不是一次通过。
合并与修复会改变问题
真实历史是有向无环图,不总是一条编号链。Git 根据祖先关系选择候选;合并后的表现可能由两边交互产生,不能把图形上的左右位置当作版本顺序。
另外,若性质是“好、坏、修好、再次坏”,单一阈值假设已经失效。选择两个端点可能定位到某次转变,却不能保证发现整个历史中最早的失败。性能阈值遇到测量噪声也会使同一版本时好时坏,应先固定负载、环境和判定统计量,再考虑重复测量的成本。
--first-parent 改变要搜索的祖先路径,回答的是主线哪次引入变化;不等于自动找出分支内部最早的原始改动。定位后还应重测候选和相关父版本、检查差异,排除环境漂移,再解释为何这个改动触发输入。
flowchart LR
A[同一版本多次判定一致吗] --> B{是}
B -- 否 --> C[先固定依赖 输入 与测量规则]
B -- 是 --> D[按祖先关系缩小范围]
D --> E{最后有不可测试候选吗}
E -- 有 --> F[报告候选集合 不猜唯一提交]
E -- 无 --> G[复测候选与父版本]
G --> H[结合差异解释回归机制]
完整实验:只新建独立裸仓库
下面代码需要 Python 3 和本地 Git。保存为 bisect_lab.py,运行 python bisect_lab.py。它在当前目录下新建两个不同的实验目录;保留对象、日志和 bisect 状态供检查,不连接远端,不修改现有仓库,不清理目录。
为了把实验与工作区修改隔离,代码使用 hash-object、mktree、commit-tree 创建九个小快照。它们是 Git 对象写入命令,不是提交日常业务代码的推荐流程。实验使用裸仓库和 --no-checkout,判定器通过 BISECT_HEAD:case.json 读取候选对象;普通 HEAD 始终停在 c8。只读取 HEAD 会测错版本。
1 | import json |
实测结果与可复核范围
本地 Git 2.41.0.windows.3 实验中,普通情况依次测试 c4、c6、c5,run_exit=0,候选仅 [6]。跳过 c6 后返回非零,保留 [6,7]。目录名和对象 ID 取决于运行位置或对象格式,不应把它们硬编码为定位结果。
测试程序还改变首坏位置并独立逐个读取九个对象作参考;不通过日志措辞猜测唯一候选。非零退出状态必须结合候选和日志解释,不能把“脚本运行结束”当作“成功找到唯一提交”。这里的判定器只读取 JSON;它没有验证历史版本的完整构建、真实网络环境或依赖安装。
在自己的项目中,先确认未提交修改已妥善保存,使用团队认可的隔离环境运行历史快照;实际测试若依赖编译文件,不能把上面仅从对象读 JSON 的判定器直接拿来测工作区。本文不在博客部署仓库里启动二分,也不改变发布分支。
自检:跳过首坏版本,为什么不等于定位到下一次提交?
c5 通过、c7 失败且 c6 未知时,两种历史都符合已有观测:c6 开始坏,或 c7 才开始坏。因此必须保留两个候选。补齐 c6 的可重复测试后才有条件进一步区分。