backend / git / 06_bisect.md

Bisect — Find the Commit That Broke It

5 interview angles 5 min read source

Bisect — Find the Commit That Broke It

git bisect does binary search through commit history to find the first commit that introduced a bug. Across 1000 commits, it takes ~10 steps. Across 1M commits, ~20 steps. The math is unbeatable.

When you reach for it: “this used to work last week, somewhere in the last N commits I broke it, and I can’t read all the diffs.”

The basic flow

git bisect start
git bisect bad                # current HEAD is broken
git bisect good v1.4.0        # this tag was working

# Git checks out the middle commit
# You test, then mark:

git bisect good               # ...this commit works
# or
git bisect bad                # ...this commit doesn't

# Git checks out the next middle commit. Repeat.

# Eventually:
# a1b2c3d4 is the first bad commit
# commit a1b2c3d4
# Author: ...
# Date: ...
#     Refactor user serializer

git bisect reset              # return to original branch

Each step halves the search space. 1024 commits → 10 steps. 1,048,576 → 20 steps.

Marking commits

Three states per commit:

Mark Means
git bisect good [<commit>] this commit (or current HEAD) works
git bisect bad [<commit>] this commit (or current HEAD) is broken
git bisect skip [<commit>] can’t test this one (broken build, missing dependency) — bisect picks an adjacent commit

You can mark multiple at once at the start:

git bisect start HEAD v1.4.0    # bad=HEAD, good=v1.4.0

Automate with a script

git bisect run <script> runs the script at each step and uses the exit code to mark:

Exit code Bisect interprets as
0 good
1–124, 126–127 bad
125 skip (can’t test)
128+ abort bisect
git bisect start HEAD v1.4.0
git bisect run pytest tests/test_thing.py::test_broken

Now bisect runs the test on each candidate commit and finds the breaker without you watching. Coffee break.

A custom test script:

#!/bin/bash
pip install -e . > /dev/null 2>&1 || exit 125     # skip if install fails
python -c "import myapp; myapp.do_thing()" || exit 1
exit 0

Save as bisect-test.sh, chmod +x, then git bisect run ./bisect-test.sh.

What makes a good bisect target

The check should be:

  1. Deterministic — same commit always gives same answer.
  2. Fast — runs in seconds, not minutes (you’ll do it 10–20 times).
  3. Specific — pinpoints the bug, not a broader symptom.

If the test is flaky, bisect lies. If it’s slow, the binary search advantage evaporates.

Common pitfalls

The build is broken on intermediate commits

If the codebase has commits that don’t compile/install, the test fails for the wrong reason — bisect marks them bad and points you at the wrong commit.

Fix: in the test script, detect “broken environment” and exit 125 so bisect skips.

Database migrations between commits

Bisecting across commits with schema changes means each step needs the right schema. Two options:

  • Reset the test DB at each step in the bisect script.
  • Bisect within commits that share a schema, then handle the schema change separately.

Generated/binary files in the working tree

Switching commits doesn’t clean up files generated by the build. Stale *.pyc, build outputs, or node_modules from a different commit can break tests.

git bisect run sh -c 'find . -name __pycache__ -exec rm -rf {} +; pytest tests/'

Visualizing where you are

git bisect log               # show the bisect history (good/bad marks so far)
git bisect visualize         # open a viewer with remaining suspects
git bisect status            # how many revisions left to test

Save the log if you want to reproduce:

git bisect log > bisect.log
# later:
git bisect replay bisect.log

Bisecting non-functional regressions

Performance regression: write a test that fails if it runs slower than a threshold.

# perf_test.py
import time, myapp
start = time.time()
myapp.process_data()
assert time.time() - start < 0.5, "regression"
git bisect run python perf_test.py

Same trick works for memory regressions, output diffs, anything quantifiable.

Bisect and merge commits

By default bisect tests both sides of a merge. If bug was introduced by a merge that brought in 50 commits from another branch, bisect can dive into those.

To restrict: git bisect start --no-checkout and use git bisect skip to ignore branches you don’t want to traverse.

Common interview confusions

  • “Bisect is slow because git checks out a commit each time.” — it’s still O(log N). 20 checkouts to find one bad commit in a million is fast.
  • “Bisect modifies history.” — it just checks out commits. git bisect reset returns to your starting branch with no changes.
  • “You need to know where the bug was introduced to bisect.” — you only need a known-good commit (any older version where it worked) and a known-bad one (HEAD).

Interview angle

  • “How would you find which commit introduced a bug across 500 commits?”git bisect. Mark a known-good commit and the broken HEAD; git binary-searches in ~9 steps. Automate with git bisect run <test-script>.
  • “What’s the advantage of git bisect run?” — automates the test/mark loop. Walk away while git finds the commit. Exit code conventions: 0=good, non-zero=bad, 125=skip, 128+=abort.
  • “What if some intermediate commits don’t even build?”git bisect skip (manual) or exit 125 from the test script (automatic) — bisect picks an adjacent commit.
  • “What kinds of regressions can bisect find beyond bugs?” — anything quantifiable: performance regressions (test against a time threshold), output changes, memory growth.
  • “You bisected and it pointed at a merge commit — what next?” — the merge brought in many commits; either bisect finer (dive into the merged branch) or read the merge to identify the responsible change.