差异审查解释一次提交保存了什么。另一个常见问题是:现在测试失败,一个较早版本能通过,究竟哪次提交引入了这个变化?逐个往回试可以找到答案,却可能重复运行许多次昂贵的测试。

git bisect 能减少要测的版本数,但它不会替你定义“坏”。真正决定结论是否可信的是判定器:它是否在每个版本上测量同一行为,能否把目标失败与环境故障分开?

先固定行为

同一输入、同一预期,确认两个端点。

再缩小区间

保持左端通过、右端失败的不变量。

最后核验

跳过意味着未知,定位之后仍要解释因果。

c0到c8的线性历史在c6开始失败,依次测试c4通过、c6失败、c5通过可定位c6;跳过c6则只能保留c6与c7两个候选
查到的是判定器的边界。 图中历史和故障是人工构造的实验;下方会在真实 Git 裸仓库中验证唯一定位和跳过后的歧义。查看原图

先说清楚什么才算坏

把输入固定为 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 提交图的精确调用次数保证。

九个版本的手算

候选引入点最初为 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 改变要搜索的祖先路径,回答的是主线哪次引入变化;不等于自动找出分支内部最早的原始改动。定位后还应重测候选和相关父版本、检查差异,排除环境漂移,再解释为何这个改动触发输入。

完整实验:只新建独立裸仓库

下面代码需要 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile


PROBE = r'''
import json, subprocess, sys
repo, skipped = sys.argv[1], set(filter(None, sys.argv[2].split(',')))
try:
r = subprocess.run(['git', '--git-dir', repo, 'show',
'BISECT_HEAD:case.json'], capture_output=True, text=True)
if r.returncode:
sys.exit(128)
item = json.loads(r.stdout)
step, d = item['step'], item['denominator']
if type(step) is not int or type(d) is not int:
sys.exit(128)
if str(step) in skipped:
print('probe', step, 'skip', flush=True)
sys.exit(125)
ok = d != 0 and 20 // d == 2
print('probe', step, 'good' if ok else 'bad', flush=True)
sys.exit(0 if ok else 1)
except (KeyError, ValueError, OSError, TypeError):
sys.exit(128)
'''


def integer(value, low, high, name):
if type(value) is not int or not low <= value <= high:
raise ValueError(name)
return value


def experiment(first_bad=6, skip=(), base='.'):
integer(first_bad, 1, 8, 'first_bad')
skip = tuple(skip)
for step in skip:
integer(step, 1, 7, 'skip')
repo = Path(tempfile.mkdtemp(prefix='.bisect-lab-', dir=base)).resolve()
env = dict(os.environ, LC_ALL='C', GIT_AUTHOR_NAME='Bisect Lab',
GIT_AUTHOR_EMAIL='lab@example.invalid',
GIT_COMMITTER_NAME='Bisect Lab',
GIT_COMMITTER_EMAIL='lab@example.invalid',
GIT_AUTHOR_DATE='2026-10-20T09:00:00+08:00',
GIT_COMMITTER_DATE='2026-10-20T09:00:00+08:00')

def git(*args, data=None, check=True):
result = subprocess.run(
['git', '-c', 'gc.auto=0', '-c', 'commit.gpgsign=false',
'--git-dir', str(repo), *args],
input=None if data is None else data.encode('utf-8'),
capture_output=True, env=env)
result.stdout = result.stdout.decode('utf-8')
result.stderr = result.stderr.decode('utf-8')
if check and result.returncode:
raise RuntimeError(result.stderr or result.stdout)
return result

git('init', '--bare', str(repo))
commits = []
for step in range(9):
item = {'step': step, 'denominator': 10 if step < first_bad else 0}
blob = git('hash-object', '-w', '--stdin',
data=json.dumps(item)).stdout.strip()
tree = git('mktree', data='100644 blob ' + blob + '\tcase.json\n').stdout.strip()
parents = ['-p', commits[-1]] if commits else []
commit = git('commit-tree', tree, *parents,
data='step ' + str(step) + '\n').stdout.strip()
commits.append(commit)
git('update-ref', 'refs/heads/main', commits[-1])
git('symbolic-ref', 'HEAD', 'refs/heads/main')
git('bisect', 'start', '--no-checkout', commits[-1], commits[0])
result = git('bisect', 'run', sys.executable, '-c', PROBE,
str(repo), ','.join(map(str, skip)), check=False)
candidates = git('rev-list', 'refs/bisect/bad', '--not',
'--glob=refs/bisect/good-*').stdout.split()
steps = sorted(json.loads(git('show', c + ':case.json').stdout)['step']
for c in candidates)
assert git('rev-parse', 'HEAD').stdout.strip() == commits[-1]
return {'repository': str(repo), 'run_exit': result.returncode,
'candidates': steps,
'probes': [line for line in result.stdout.splitlines()
if line.startswith('probe ')]}


if __name__ == "__main__":
print(json.dumps({'normal': experiment(),
'skip_boundary': experiment(skip=(6,))}, indent=2))

实测结果与可复核范围

本地 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 的可重复测试后才有条件进一步区分。

继续阅读Git 差异审查理解候选改动,或用RAG 回归评测练习先固定判定口径。更多入口见开发工具阅读路径。