用 git bisect 找回归:先把故障写成一条能跑的判定
上周发布版把 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 官方手册。