KernelRanch
返回文章列表

用 git bisect 找回归:先把故障写成一条能跑的判定

2026-11-12 #git#debugging#regression#testing

上周发布版把 1050 分显示成 10.5 元,这周变成了 10。两个版本之间有几十次提交。先用同一笔输入验证两个端点,再让 git bisect run 自动检出中间版本并运行判定脚本。定位结果能给出一笔值得直接查看 diff 的提交。

1. 让测试区分回归和环境故障

探针应该只检查一个用户可见的行为。本例运行金额转换程序,比较输出的数值。输出 10.50 和 10.5 都通过。无法执行测试时,脚本需要告诉 Git 发生了什么。

退出码git bisect run 的处理示例
0标记当前提交为 good输出的数值为 10.5
1标记当前提交为 bad程序正常完成,但输出为 10
125跳过当前提交该历史版本已知无法使用固定的工具链运行
128 至 255中止自动二分测试工具启动失败或测试环境发生异常

Git 也会把 1 至 127 范围内除 125 之外的退出码判为 bad。直接把 npm test 的退出码交给 Git 时,缺命令或权限错误可能被误记为回归。最好由探针显式映射状态。只有确认故障局限于当前历史提交时才用 125,共享环境坏掉时应中止并修好环境。

2. 造一段历史,跑一次自动定位

保存为 bisect_lab.py,执行 python bisect_lab.py。需要 Git 在 PATH 中。脚本在临时仓库创建七笔提交,第四笔把 / 改成 //。探针放在仓库外,每次都用同一份代码判断。

import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory


PROBE = '''import subprocess
import sys
from decimal import Decimal, InvalidOperation

try:
    result = subprocess.run(
        [sys.executable, "price.py", "1050"],
        check=True, capture_output=True, text=True, timeout=5,
    )
    amount = Decimal(result.stdout.strip())
except (OSError, subprocess.SubprocessError, InvalidOperation) as error:
    print(error, file=sys.stderr)
    sys.exit(128)
sys.exit(0 if amount == Decimal("10.50") else 1)
'''


with TemporaryDirectory() as tmp:
    root = Path(tmp)
    repo = root / "history"
    repo.mkdir()
    probe = root / "probe.py"
    probe.write_text(PROBE, encoding="utf-8")

    def git(*args):
        return subprocess.check_output(
            ["git", *args], cwd=repo, text=True, encoding="utf-8",
        ).strip()

    git("init", "-q")
    git("config", "user.name", "Bisect Lab")
    git("config", "user.email", "lab@example.com")
    git("config", "commit.gpgsign", "false")
    git("config", "core.hooksPath", str(root / "unused-hooks"))
    commits = []
    for step in range(7):
        operator = "/" if step < 3 else "//"
        program = f"import sys\nprint(int(sys.argv[1]) {operator} 100)\n# revision {step}\n"
        (repo / "price.py").write_text(program, encoding="utf-8")
        git("add", "price.py")
        message = "introduce integer division" if step == 3 else f"revision {step}"
        git("commit", "-qm", message)
        commits.append(git("rev-parse", "HEAD"))

    for endpoint, expected in [(commits[0], 0), (commits[-1], 1)]:
        git("checkout", "--quiet", "--detach", endpoint)
        status = subprocess.run([sys.executable, str(probe)], cwd=repo).returncode
        if status != expected:
            raise RuntimeError(f"unexpected endpoint result: {status}")

    git("bisect", "start", commits[-1], commits[0])
    try:
        git("bisect", "run", sys.executable, str(probe))
        found = git("rev-parse", "refs/bisect/bad")
        assert found == commits[3]
        print("first bad:", git("show", "-s", "--format=%s", found))
    finally:
        git("bisect", "reset")

本地实测输出 first bad: introduce integer division。实验比较完整提交 ID,所以即使有两笔提交标题相同,也能发现定位结果不对。

3. 换成真实仓库的版本区间

先确认好版本属于坏版本的历史,再把复现脚本放到工作树外。以下在 Bash 中运行,good、bad 和探针路径按实际发布记录填写。探针要在两个端点各跑一次,结果分别为 0 和 1,再启动二分。

good=v2.4.0
bad=v2.5.0
probe=/absolute/path/to/probe.py
git merge-base --is-ancestor "$good" "$bad" || exit 1
git worktree add --detach ../bisect-lab "$bad" || exit 1
git -C ../bisect-lab bisect start "$bad" "$good" || exit 1
git -C ../bisect-lab bisect run python "$probe"
git -C ../bisect-lab bisect log
git -C ../bisect-lab bisect reset

记录 bisect log 和首个坏提交的完整 ID,再用 git show <提交> 看改动。历史中有合并提交时,还要查看它的父提交并分别跑探针,确认故障来自哪条分支或合并结果。需要删除这次单独创建的工作树时,回到原仓库执行 git worktree remove ../bisect-lab。

二分依赖这段历史里的判定结果足够稳定。偶发测试先固定时钟、随机种子或外部响应,并对两端各重复几次。如果回归曾出现、被修好又重新出现,先缩小到一次状态变化的区间。若相邻提交被跳过,Git 可能只能给出一组候选,这时保留候选名单并补测相关提交。

小结

把回归缩成一个输入、一个预期值和可区分的退出码。先验证两个端点,再运行 git bisect run,最后核对命中的提交和父提交。

数据来源与检索时间

检索时间 2026-11-12。

来源URL对应内容
Git git bisect 手册https://git-scm.com/docs/git-bisect好坏端点、run 退出码、跳过提交与日志
Git git worktree 手册https://git-scm.com/docs/git-worktree独立工作树的创建与清理
Git git merge-base 手册https://git-scm.com/docs/git-merge-base检查好版本是否是坏版本的祖先

核验说明

  • 在 Git 2.54.0.windows.1 与 Python 3.11.9 下运行临时仓库示例,验证两个端点和命中的完整提交 ID。
  • 示例使用线性历史。合并提交和不可构建版本的处理依据 Git 官方手册。