Why Not Step Back One Commit at a Time
If the bug hides among 200 commits, testing one by one in order takes up to 200 runs in the worst case. git bisect uses a binary search, cutting to the middle commit each time and asking you good or bad, converging in about log2(200)≈8 steps.
It exploits Git's smart commit selection, jumping straight to the most information-gaining commit in your good/bad interval, and you only answer whether each middle commit is good or bad.
One precondition: the history must not suffer from flaky, environment-driven failures (random test failures, external service dependence), or the good/bad answers contradict and mislead the bisect.
git bisect start
git bisect bad # 当前 HEAD 是坏的
git bisect good v1.2.0 # 已知的某个正常历史点
# Bisecting: 99 revisions left to test after this (roughly 7 steps)Automate the Good/Bad Call: Script Instead of Clicking
Running the app by hand and eyeballing the result at each middle commit gets tiring after twenty steps. A better pattern is packaging "is this commit bad" as an idempotent command — exit code 0 means good, non-zero means bad — then letting git bisect run run it automatically to completion.
A typical script checks out into a clean temp working copy, builds, and runs a minimal reproducer (pytest, or npm test -- a single case), then returns its exit code. The point is to make the test actually return non-zero on failure, with set -e or the framework's failure code.
The script must treat build failure and test failure both as bad — otherwise a one-off compile hiccup is mislabeled good and steers the bisect astray.
cat > bisect_test.sh <<"EOF"
#!/usr/bin/env bash
set -euo pipefail
# 在当前提交下干净地构建并跑最小复现用例
rm -rf /tmp/bw && git worktree add /tmp/bw HEAD
(cd /tmp/bw && npm ci && npm run build)
(cd /tmp/bw && npm run test -- --grep "balance overflow")
EOF
chmod +x bisect_test.sh
git bisect start
git bisect bad HEAD
git bisect good v1.2.0
git bisect run ./bisect_test.shHandling the Common Failure: Contradictory Labels
If bisect suddenly reports "skipped or contradicts", the interval probably has environment-dependent flakiness, or the very first "good" you picked was not actually good.
Debug in this order: dump the process with git bisect log; double-check whether good and bad are truly good and bad — a historically "sort of works" tag poisons the result; if honestly flaky, switch to a more deterministic reproducer (pinned deps, fixed ports, mocked external HTTP).
A legitimate tactic is git bisect skip for commits you cannot judge, e.g. one that does not even compile and is unrelated. skip lets bisect advance to a judgeable commit, at the cost of a few more steps but with more trust.
# 查看当前进度并核对标的
git bisect log
# 对无法判定的提交跳过
git bisect skip
# 想给区间加另一个 good 点收紧
git bisect good v1.1.0
git bisect reset # 结束时务必退出 bisect 状态Better Habits: Make Bisect Hit Every Time
Keep commits small and single-purpose so the culprit that bisect returns reads clearly and the offending line pops out at a glance. One commit packing twenty unrelated changes means even after bisect finds it you still hand-split it further.
Keep every commit compiling and green against your main tests; do not commit half-works. The whole bisect span should ideally build, or middle steps waste judging on build failure.
Write a fast, minimal reproducer for critical paths so git bisect run gives sub-second feedback; otherwise each step runs the full integration suite and eight steps become a whole day.
git log --oneline HEAD~10 # 小块提交的历史长这样:
# 7ad3 Add cache header to image route
# 61f2 Fix round calculation in quote
# 98aa Rename billing module files
# ...
# 配合单一用例快速复现:
# package.json "scripts": { "test:bw": "vitest run tests/repro.spec.ts" }
git bisect run ./bisect_test.sh