bisecting-failures

Use when something worked before and is broken now, when the culprit is one of many commits, config keys, dependency upgrades, data rows, or uncommitted edits, when git bisect runs into build failures, flaky tests, or merge-heavy history, or when there is no version control to search at all. Covers binary search over any axis of change and writing an unattended bisect script.

Overview

Binary search applies to any set of differences you can split in half and test, not just commit history. Bisection converts an unbounded "what broke it" question into ⌈log₂ n⌉ mechanical runs, which is why it beats reading the diff whenever n exceeds about 20.

When to use

  • A known-good state and a known-bad state exist and something between them is the cause
  • The diff between them is too large to read, or spans repos you do not control
  • A dependency upgrade, config change, or data import preceded the failure
  • The working tree has accumulated many edits and one of them breaks the build
  • Reading the change set has already failed once

Not for: a failure with no known-good state (nothing to bisect between — find the defect directly), or a test that fails at an unmeasured rate (reproducing-bugs first, then come back with a number).

Preconditions, in order

  1. Verify both ends by running the test. Assuming the old version was good is the most common way a whole search is wasted; roughly one time in five it was never good and you are searching an empty interval.
  2. Have a test that discriminates. Deterministic, or with a measured reproduction rate. Narrow: a single case, not the suite.
  3. Make each step independent. Clean build outputs, reset database and caches, reinstall dependencies from the candidate's own lockfile. Stale artifacts leaking between steps is the number one cause of a confidently wrong bisect answer.
  4. Do the cost arithmetic. ⌈log₂ n⌉ runs: 1,000 candidates is 10 runs, 1,000,000 is 20. At 20 minutes per run that is 3+ hours — reduce the test before starting, not after run 6.

The axes

AxisHow to halveGotcha
Commitsgit bisect start bad good, then git bisect run <script>needs clean builds at every point
Uncommitted working-tree editssplit the diff, apply half, test, recursehunks are often not independently applicable — split by file first, hunk second
Input datahalve rows, records, or bytesboth halves passing means an interaction; split by a different dimension
Configurationstart from the known-good config, apply half the deltasordering and defaults matter; some keys only take effect together
Dependency versionspin one dependency at a time, halve its release listtransitive resolution changes underneath you; lock the whole graph
Environmenthalve the environment variable or feature-flag setsome variables are only read at process start
Timehalve over nightly builds, dated artifacts, or backupsworks with no version control at all
Deployment fleethalve the set of hosts, regions, or shardstells you it is environmental, not code

Bisecting the input and bisecting the code are different searches with different answers. Run the input search first when the failure is data-dependent: it is usually faster and it hands you a minimal reproduction as a side effect.

The bisect script

The script is what makes bisection unattended, and its exit codes are what make it correct.

build || exit 125          # cannot test this candidate — skip, do not judge
setup_clean_state
timeout 300 run_one_test   # hard timeout, or one hang stalls the whole search
case $? in
  0)   exit 0 ;;           # good
  124) exit 125 ;;         # timed out: skip unless the hang IS the bug
  *)   exit 1 ;;           # bad
esac
  • 0 = good, 1–124 and 126–127 = bad, 125 = untestable/skip, 128–255 = abort the run. Getting 125 right is the difference between a broken intermediate build being skipped and it being scored as good, which lands you on an innocent commit.
  • A script that exits 0 when the build fails marks every unbuildable candidate "good" and reliably blames the wrong change. Fail loudly toward 125, never toward 0.
  • Decide once, in a comment at the top, which direction is "good". When the bug is "a feature stopped working" rather than "a crash appeared", the polarity inverts and everyone gets it backwards at least once.
  • Append revision and result to a log file. Bisect state is easy to lose and expensive to recreate.
  • Keep the test script outside the tree being bisected (a copy in a temp path), or checkouts will replace it mid-search.

When bisect misbehaves

SymptomCauseMove
Answer is a plausible-looking innocent commitstale artifacts, or a build failure scored as goodrerun with a clean-build guard and verify the accused commit by hand
Flaky testeach step's verdict is a coin fliprun k times per step, call good only if all k pass. At reproduction rate p, k = ⌈ln 0.05 / ln(1−p)⌉ — 5 at p=0.5, 14 at p=0.2. If k > 10, fix the flake first
Long unbuildable stretchbroken intermediate historyskip individually; if a whole region is dead, cherry-pick the test onto each candidate instead
Merge-heavy historycommits from a branch interleave with mainlinebisect first-parent only to find the offending merge, then bisect inside that branch. Two cheap searches beat one confusing one
Culprit is a merge committhe defect is an interaction between two branchesdiff the merge against each parent; neither side is wrong alone
Lands on a revert or a "fix the test" commitintroduced, reverted, reintroducedabandon bisect; search the history for every touch of the relevant symbol or file
Result contradicts the good endthe good end was never goodre-verify both ends and restart
Every step is badthe bug predates your "good"move the good end further back by doubling the distance

Bisecting without version control

The change set is still a set. Halve it directly.

  • Two artifacts, one working and one not: move half the files from good to bad, test, recurse. Works on deployed bundles, containers, and firmware images.
  • Configuration: start from the shipped default (known good by construction) and apply half the local settings.
  • Dependencies: diff the two installed manifests and bisect the version differences.
  • Compare the running artifacts, not the sources. What is deployed and what is in the tree diverge more often than anyone expects, and that divergence is itself frequently the answer.

Common mistakes

SymptomReal cause
"Bisect gave a nonsense answer"build artifacts, caches, or dependencies not reset between steps
Manual bisect abandoned halfwayno script, so each step cost human attention and the search was never finished
Bisect run takes all dayfull suite per step instead of the one failing case
Bisect blames a whitespace or formatting committhat commit rebuilt something the incremental build had been serving stale
Found the commit, still do not know whybisection names the change, not the mechanism — the commit's diff is the start of the real investigation, and diagnosing-before-fixing is what that investigation looks like
Bisecting a bug reported today across a year of historythe failure may be environmental; verify the old build fails now before assuming a code cause

Red flags

  • "I'll just read the diff" when the range is more than about 20 commits
  • Starting the search without running the test on the good end
  • Marking a candidate by hand because "that one obviously isn't it"
  • A bisect script with no timeout, or with no path that exits 125
  • Accepting the answer without reverting the accused change and confirming the failure disappears — confirming-before-claiming-done on the one claim a whole search exists to produce

More in Debugging and performance