---
title: "用 git bisect 找回归：先把故障写成一条能跑的判定"
date: 2026-11-12
slug: git-bisect-regression-probe
canonical: https://kernelranch.com/blog/2026/11/git-bisect-regression-probe
markdown_url: https://kernelranch.com/blog/2026/11/git-bisect-regression-probe.md
tags: ["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` 中。脚本在临时仓库创建七笔提交，第四笔把 `/` 改成 `//`。探针放在仓库外，每次都用同一份代码判断。

```python
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`，再启动二分。

```bash
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 官方手册。
