git 内置、用于定位首个失败变更的二分查找。

git bisect 用自动探针定位回归

自动二分会反复检查历史版本,并把测试结果转成对提交的判断。在简单区间里,它通常能用近似对数级的探针次数替代大量手动检出,但前提是测试可信。包装脚本若把工具缺失或基础设施故障错误归类为产品回归,就可能给出看似明确、实际错误的提交。因此,设计判断契约本身就是调试工作的一部分,不能当作搜索外面无关紧要的准备步骤。 先确定能够复现的问题 选择一个可观察的回归,并用同一个执行器验证计划采用的正常端点和异常端点。某个发布名字被认为没问题,证据弱于在那个准确版本上实际检查成功。输入、工具链、依赖和相关环境应保持足够一致,让源码版本成为真正接受检验的变量;否则即使最后缩小到一个提交,也无法知道改变结果的究竟是代码还是环境。 启动时先给出异常版本,再给出已知正常版本。Git 会选择中间提交,并从当前检出的工作树调用探针。执行器应放在不会随历史切换而消失的稳定路径,或确保每个待测版本都包含可用实现。相对路径脚本在搜索一半时不存在,属于测试设施问题,不是产品恰好从那次提交开始损坏的证据。 按真实退出码契约映射结果 自动运行模式中,零表示正常;一到一百二十七表示异常,但一百二十五是例外,代表当前版本无法测试,需要跳过。其他状态值会中止自动搜索。这与“所有非零都说明发生回归”的简化说法不同,包装脚本必须明确区分结果,而不能把任何失败都交给 Git 当作同一种判断。 文档规定的异常区间包含常用于命令不可执行或不存在的一百二十六和一百二十七。应提前检查必需工具,并把识别出的基础设施失败映射到中止状态,例如二百。不能不看测试工具自身的含义,就原样转交所有非零返回。另外,超出可表示范围的 Shell 退出值可能被截断,因此需要选择明确、受支持的状态,不能随意用一个巨大数字表示致命错误。 跳过适用于某个确实无法测试、结论未知的版本,而不是所有构建失败。如果正在寻找的回归本来就是构建损坏,把构建失败跳过就等于丢掉主要证据。反过来,与症状无关的过时依赖,也不应自动把旧提交判坏。执行器为什么把两类情况区别对待,需要有可检查的依据,并在日志中保留相应原因。 坏掉的样子 测试输出经过管道时,最后的日志命令成功可能掩盖测试失败;清理命令也可能覆盖原状态。应先保存测试结果,再记录诊断,最后明确返回映射后的判断值。标准错误上出现了一段异常文字,并不会阻止 Git 接受意外返回的零。自动化读取的是状态契约,不会根据人类看起来很严重的日志重新推断好坏。 修改源码的测试、复用陈旧构建产物的测试,以及依赖持续变化远端服务的测试,都可能让不同版本之间失去可比性。应隔离生成文件,并保留足够日志解释每次判断。不要为了让探针可重复,就清空宽泛的用户目录;应使用可丢弃且归属明确的测试环境,把清理范围限定到自己创建的路径。 靠近转换边界的跳过,可能留下多个首坏候选。随机失败或非单调变化的判断条件,也会破坏简单边界假设;增加次数不能修复错误标签。归因之前应检查搜索日志,在报告出的边界及相关父级重新测试。提交图、跳过版本和不同构建耗时还意味着,对数级探针次数并不等于固定的实际运行时间。 快速验证 使用 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-run-") as directory: root = Path(directory) repo, probe = root / "repo", root / "probe.py" repo.mkdir() probe.write_text('''from pathlib import Path import sys try: state = Path("state.txt").read_text().strip() except OSError: sys.exit(200) code = {"good": 0, "bad": 1, "skip": 125}.get(state, 200) print(f"probe={state} code={code}") sys.exit(code) ''') def git(*args, check=True): return subprocess.run( ["git", "-c", "user.name=Bisect Example", "-c", "user.email=bisect@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, env=env, text=True, capture_output=True, check=check, ) git("init", "-q", "-b", "main") commits = [] for number in range(9): state = "skip" if number == 4 else ("bad" if number >= 7 else "good") (repo / "state.txt").write_text(state + "\n") git("add", "state.txt") git("commit", "--allow-empty", "-qm", f"revision {number}") commits.append(git("rev-parse", "HEAD").stdout.strip()) codes = [] for revision in (commits[0], "main"): git("switch", "-q", "--detach", revision) codes.append(subprocess.run([sys.executable, str(probe)], cwd=repo, text=True, capture_output=True).returncode) git("switch", "-q", "main") print("endpoints-verified:", codes == [0, 1]) git("bisect", "start", commits[-1], commits[0]) try: run = git("bisect", "run", sys.executable, str(probe)) print("skip-observed:", "probe=skip code=125" in run.stdout) print("first-bad-correct:", git("rev-parse", "refs/bisect/bad").stdout.strip() == commits[7]) finally: git("bisect", "reset") git("bisect", "start", commits[-1], commits[0]) try: before = git("bisect", "log").stdout fatal = git("bisect", "run", sys.executable, "-c", "raise SystemExit(200)", check=False) print("fatal-did-not-classify:", fatal.returncode != 0 and git("bisect", "log").stdout == before) finally: git("bisect", "reset") print("original-checkout-restored:", git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main" and git("rev-parse", "HEAD").stdout.strip() == commits[-1] and not git("status", "--porcelain").stdout) 五个观察都应为真。执行器先确认两个端点,搜索中实际遇到跳过码,仍然正确定位第七号版本。第二次搜索故意返回二百,应中止且不向二分日志增加新的好坏判断。两次搜索最后都恢复原分支与原末端,并保持工作树干净,避免把仍处于排查状态误当作已经回到正常开发位置。 ...

七月 7, 2027 · 2 分钟 · XB Field Notes