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:
- Deterministic — same commit always gives same answer.
- Fast — runs in seconds, not minutes (you’ll do it 10–20 times).
- 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 resetreturns 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 withgit 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) orexit 125from 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.