二分排查回答一个明确的历史问题:在选定历史中,某个可复现观察从哪里开始由正常变成异常?它不会仅凭给出一个提交,就自动确定根因。搜索依赖正确的端点标签和一致的中间判断。应把报告出的边界当作需要检查的证据,再确认边界两侧行为,并解释哪项变化造成了结果差异,不能把一行定位输出当作完整故障分析。
依据观察选择端点,而不是日期 至少一个已知正常提交和一个已知异常提交可以建立搜索;更多正常版本还可以进一步约束提交图。最近经过验证的正常版本,通常比不必要地选择很古老的基线更能缩小范围。日期不是搜索顺序:Git 使用祖先关系,作者时间戳不一定反映提交在图上的位置。因此,“最早”应放在这次历史边界内解释,而不是直接按墙上时间排序。
赋予标签之前,应在两个端点运行同一份复现。明确什么算目标症状,包括边界输入与相关配置。只检查任意命令有没有失败,可能把产品回归与解释器缺失、服务不可用或构建环境不兼容混在一起。一个无法测试的版本,其结论是未知,而不是自动判坏;如果输入条件没有保持一致,后续再精细的搜索也无法补回缺失的可比性。
使用干净、可丢弃的检出,或专门的关联工作树。二分通常会把 HEAD 切到选定版本并更新文件,因此应提前保存未完成工作,不能认为搜索会自动创建备份。探针和必需证据也应放在不会随历史检出而消失或改变的地方,让测试程序自身的变化与被测源码版本分开,避免把旧脚本缺失当成源码故障。
每次都判断同一个性质 观察当前版本之后,按复现结果标记正常或异常。某个版本由于与目标症状无关的原因无法分类时,可以跳过。靠近转换点的跳过可能使答案不唯一,此时应报告剩余候选集合,而不是把最先显示的对象包装成已经确定的责任提交。未知本身也是有效调查结果,不能为了快速收束而强行填上好坏标签。
简单二分直觉要求所选历史中的转换足够稳定。问题先引入、后修复、再重新引入时,对这个判断条件来说已经不是单一的单调时间线;随机失败的测试也会制造类似的误导。观察与假设矛盾时,应细化问题或区间,而不是把不一致结果塞进方便解释的故事。搜索算法不会替你决定这些不一致到底意味着什么。
合并较多时,搜索全部祖先还是只沿第一父级集成线,会改变问题本身。第一父级搜索可能找到集成分支因某次合并而出现故障的点,而不是主题分支最初引入代码的提交。同样,限制文件路径可能排除真正相关的配置或调用方变化。只有这些限制符合调查目标时才应用,不能只为了让候选数量看起来更少而缩小问题。
自动运行 git bisect run 时,退出码零表示正常,125 表示跳过,1 至 127 中其余值表示异常,其他状态会中止搜索。命令不存在可能返回 127,因此包装脚本必须区分工具不可用与目标回归,不能把每个失败都直接转交为坏版本。手动探针中的异常同样应先调查,而不是自动贴标签。
坏掉的样子 一个错误标签就可能排除真正边界。结束当前排查之前保存搜索日志;发现判断有误时,保留原证据,修正决定记录,重置会话后重放修正日志。重放恢复的是判断过程,不会重新执行测试,也不能证明当时的环境现在仍然有效。若探针或依赖发生变化,需要重新验证对应结论,不能把可重放等同于已复测。
找到候选后,在候选及相关父级运行准确的复现。普通线性边界应表现为候选异常、父级正常;合并提交则可能需要检查多个父级和组合行为。还应阅读补丁及受影响调用链。即便候选只是暴露了更早就存在的问题,也仍需解释因果关系,不能只凭它改变了症状,就把全部根因归给那次提交。
不要为了验证猜测,就立刻在共享或有未提交修改的分支撤销候选。修正实验应放在受控检出中,并单独审阅和测试。记录发现之后,二分重置会结束会话,通常回到开始时的检出;还要核对当前分支和版本,而不能把最后用于诊断的历史位置误认成日常开发分支,随后把新工作提交到错误位置。
快速验证 在 POSIX 系统上使用 Python 3 与 Git 2.28 或更新版本运行下面实验。临时仓库使用示例身份,关闭钩子及签名,排除个人 Git 配置和路由变量,没有远端。七个版本中,第三号版本把数值上限从包含等号改成不包含等号;探针专门检查边界输入十。这样测试的是一个明确行为,而不是根据提交消息或预设对象标识给版本贴标签。
import os import subprocess import sys import tempfile from pathlib import Path env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) with tempfile.TemporaryDirectory(prefix="git-bisect-boundary-") as directory: root = Path(directory) repo, log = root / "repo", root / "bisect.log" repo.mkdir() def git(*args): return subprocess.run( ["git", "-c", "user.name=Boundary Example", "-c", "user.email=boundary@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, env=env, text=True, capture_output=True, check=True, ) def verdict(): result = subprocess.run([sys.executable, "-B", "-c", "from limit import accepts; print(int(accepts(10)))"], cwd=repo, text=True, capture_output=True, check=True) assert result.stdout in ("0\n", "1\n"), result return "good" if result.stdout == "1\n" else "bad" git("init", "-q", "-b", "main") commits = [] for number in range(7): operator = "<" if number >= 3 else "<=" (repo / "limit.py").write_text(f"def accepts(n):\n return n {operator} 10\n") (repo / "revision.txt").write_text(f"{number}\n") git("add", "limit.py", "revision.txt") git("commit", "-qm", f"revision {number}") commits.append(git("rev-parse", "HEAD").stdout.strip()) endpoints = [] for revision in (commits[0], "main"): git("switch", "-q", "--detach", revision) endpoints.append(verdict()) git("switch", "-q", "main") print("endpoints-confirmed:", endpoints == ["good", "bad"]) git("bisect", "start", commits[-1], commits[0]) try: while len(git("rev-list", "refs/bisect/bad", "--not", "--glob=refs/bisect/good-*").stdout.splitlines()) > 1: git("bisect", verdict()) found = git("rev-parse", "refs/bisect/bad").stdout.strip() log.write_text(git("bisect", "log").stdout) print("boundary-identified:", found == commits[3]) finally: git("bisect", "reset") checks = [] for revision in (found, f"{found}^"): git("switch", "-q", "--detach", revision) checks.append(verdict()) git("switch", "-q", "main") print("candidate-and-parent-retested:", checks == ["bad", "good"]) try: git("bisect", "replay", str(log)) print("log-replays-boundary:", git("rev-parse", "refs/bisect/bad").stdout.strip() == found) finally: git("bisect", "reset") print("original-branch-restored:", git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main" and git("rev-parse", "HEAD").stdout.strip() == commits[-1] and not git("status", "--porcelain").stdout) 五个判断都应为真。在这份线性且没有跳过的样例中,循环逐次记录正常或异常,直到只剩一个候选。最终定位第三号版本,独立复测确认候选在边界输入上失败、父级成功。重放保存的日志可以到达同一结果,但不会再次执行探针;最后恢复 main。补丁也解释了观察:上限处的相等情况被排除,正是这次回归的具体来源。
...