Automating git bisect with a test script Jump to heading

Manual bisection works and is tedious enough that people abandon it halfway. Fourteen rounds of building, running a reproduction, interpreting the result and typing good or bad is fourteen chances to mislabel a commit, and a single mislabel sends the search into the wrong half and produces a confident wrong answer. git bisect run removes the human from the loop entirely β€” the only thing that needs care is the script, and specifically its exit codes. This recipe writes one, within bisect and history forensics.

When to use this approach Jump to heading

  • A regression exists and you can reproduce it with a command.
  • The suspect range is more than about ten commits.
  • Building and testing one commit takes less than a few minutes.
  • The failure is deterministic β€” if it is not, start with bisecting a flaky failure.
  • If the range is five commits, read them; bisection has setup cost that will not pay back.

Step 1 β€” Reduce the reproduction to an exit code Jump to heading

Everything else depends on this being fast and unambiguous.

# From a full suite run to the single failing case
npm test -- --testNamePattern 'clamps the refund window' 2>&1 | tail -5
echo "exit: $?"
# Better still: a standalone script with no test-runner overhead
cat > /tmp/repro.mjs <<'JS'
import { clampRefundWindow } from './src/payments/refund.js';
const got = clampRefundWindow(120);
process.exit(got === 90 ? 0 : 1);
JS
node /tmp/repro.mjs; echo "exit: $?"
# Verification: the reproduction must be fast β€” this is multiplied by every step
time node /tmp/repro.mjs
Why the reproduction's speed is the whole costBisection runs the test once per step, so a fourteen-step search over a four-minute test costs nearly an hour while the same search over a two-second reproduction finishes before you have finished reading the log.total time for a 14-step bisect4-minute suite56 min40-second subset9 min2-second script0.5 mintime spent narrowing the reproduction is repaid fourteen times over

Step 2 β€” Write the script with three outcomes Jump to heading

Good and bad are obvious. The third β€” untestable β€” is what separates a reliable bisect from a misleading one.

#!/usr/bin/env sh
# bisect-test.sh
#   exit 0   β†’ good (the bug is absent)
#   exit 1   β†’ bad  (the bug is present)
#   exit 125 β†’ skip (this commit cannot be judged)
set -eu

# A commit that does not build tells us nothing about the bug.
npm ci --silent --ignore-scripts >/dev/null 2>&1 || exit 125
npm run --silent build >/dev/null 2>&1 || exit 125

node /tmp/repro.mjs
# Verification: exercise all three paths deliberately
sh bisect-test.sh; echo "current commit: $?"

Returning 1 from a build failure is the classic mistake: Git concludes the bug is present, halves the range in the wrong direction, and the answer it eventually produces is a commit that never had the bug.

The three exit codes and what each assertsZero asserts the bug is absent at this commit. One asserts it is present. 125 asserts nothing at all, and is the only honest answer when the commit cannot be built or the reproduction cannot run.What did the script actually learn at this commit?the bug is absentexit 0goodthe bug is presentexit 1badcould not tellexit 125skipconflating the third case with the second is the most common bisect error

Step 3 β€” Verify the endpoints before starting Jump to heading

The search is only as good as its bounds.

git switch --detach v2.6.0 && sh bisect-test.sh && echo "good endpoint confirmed"
git switch --detach main   && sh bisect-test.sh || echo "bad endpoint confirmed"
# How many commits are in play?
git rev-list --count v2.6.0..main
# Verification: expect roughly log2(range) steps
python3 -c "import math,sys; print(math.ceil(math.log2(int(sys.argv[1]))))" \
  "$(git rev-list --count v2.6.0..main)"

Step 4 β€” Run it, in a worktree of its own Jump to heading

git worktree add --detach ../app-bisect main
cd ../app-bisect

git bisect start main v2.6.0
git bisect run sh ../app/bisect-test.sh
# The result, and the commit itself
git bisect log | tail -5
git show --stat "$(git rev-parse refs/bisect/bad 2>/dev/null || echo HEAD)"
# Verification: confirm by hand at the identified commit and its parent
git switch --detach "$(git rev-parse HEAD)"   && sh ../app/bisect-test.sh || echo "bad, as expected"
git switch --detach "$(git rev-parse HEAD~1)" && sh ../app/bisect-test.sh && echo "parent is good"

That final confirmation is worth the thirty seconds. A bisect result that has been verified at the commit and its parent is a fact; one that has not is a strong hypothesis.

git bisect log > /tmp/bisect-refund-window.log
head -5 /tmp/bisect-refund-window.log
# Anyone can replay it exactly, including on another machine
git bisect replay /tmp/bisect-refund-window.log
git bisect reset
cd ../app && git worktree remove ../app-bisect
What makes a bisect result trustworthyA verified pair of endpoints, a script that distinguishes untestable commits, a run in an isolated worktree, and a manual confirmation at the result and its parent. Skipping any of the four produces an answer that may be confidently wrong.Endpoints verifiedboth run, both checkedThree exit codes0, 1, 125Isolated worktreeHEAD moves freelyConfirmed by handcommit and parentthe last box converts a hypothesis into a finding

SAFETY WARNING β€” a bisect script runs at every commit in the range, and npm ci executes whatever lifecycle scripts that commit’s lockfile specifies. Bisecting through a range that includes an untrusted contribution means executing code from every one of those commits. Use --ignore-scripts where the build permits, and run the search in a container when the range includes anything you have not reviewed.

Recording the log matters beyond reproducibility: a colleague replaying it sees exactly which commits were tested and how each was judged, which is the difference between β€œbisect says it was this commit” and an investigation someone else can check.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What if the reproduction needs a service or a database? Jump to heading

Start it in the script and stop it afterwards, returning 125 if it fails to start. Keep the setup inside the script rather than around the bisect, or a service that dies midway through the search silently converts the remaining steps into false results.

Can I bisect a performance regression rather than a failure? Jump to heading

Yes β€” have the script measure and compare against a threshold, exiting 1 when the measurement exceeds it. The caveat is noise: a threshold close to the normal variation produces a flaky test, and the search will converge on nothing useful. Pick a threshold well clear of the noise floor, and average several runs.

How do I handle commits where the test file itself does not exist? Jump to heading

Keep the reproduction outside the repository, as in Step 1, so it does not change as the search moves. A test that lives in the tree changes with each commit, which means the search is testing a moving target and its results are not comparable.