A cart total that used to be 59.97 now comes out as 60. It was right in the v1.4.0 tag and it’s wrong on main, and there are 15 commits in between. You could read all 15 diffs. Or you can let Git pick the middle commit, test it, throw away half the range, and repeat. That’s git bisect, and with a good test it finds the culprit in four steps.
The interview version of this question is rarely “what does bisect do?” It’s closer to “you have a regression somewhere in the last two weeks of commits, how do you find it?” The answer they’re listening for has two halves. One is the mechanics. The other, which most candidates skip, is how you make each good/bad decision trustworthy, because bisect is only as accurate as its weakest classification.
The binary search, in Git terms
You give bisect one commit where the bug is present (bad) and one where it isn’t (good). Git checks out a commit roughly halfway between them, in a detached HEAD state, and waits for you to say git bisect good or git bisect bad. Each answer halves the candidates. For n commits it takes about log2(n) steps: 15 commits need about four tests, 1,000 need about ten.
The manual loop looks like this:
git bisect start main v1.4.0 # bad first, then good
# Git checks out a midpoint; run your check
git bisect good # or: git bisect bad
# ...repeat until Git prints "is the first bad commit"
git bisect reset # go back to where you startedForgetting git bisect reset is a common slip. Until you run it you’re on a detached HEAD in the middle of history, and new commits made there aren’t on any branch.
Automating it with git bisect run
Typing good and bad by hand is slow and invites mistakes. git bisect run <cmd> runs a command at each step and reads its exit code: 0 means good, 1 to 127 means bad (except 125), and 125 means “this revision can’t be tested, skip it.” Anything 128 or above aborts the bisect.
The script must live outside the repository, or at least outside anything bisect will check out. Otherwise old commits won’t have it, or will have an older version of it. Here’s the check for the cart bug, saved as ../check-total.sh:
#!/bin/sh
# Step 1: does this revision even load? If not, exit 125 so bisect skips it.
node --input-type=module -e "await import('./price.js')" 2>/dev/null || exit 125
# Step 2: the actual symptom. 0 = good, 1 = bad.
node --input-type=module -e "
import { total } from './price.js';
const got = total([{ price: 19.99, qty: 3 }]);
process.exit(Math.abs(got - 59.97) < 1e-9 ? 0 : 1);
"Running it against the throwaway repository built for this guide (Git 2.54):
$ git bisect start main v1.4.0
$ git bisect run ../check-total.sh
Bisecting: 7 revisions left to test after this (roughly 3 steps)
[e603bc3...] WIP: split checkout module
running '../check-total.sh'
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[83bc7ab...] Finish checkout module split
running '../check-total.sh'
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[68ece1f...] Round prices before summing
running '../check-total.sh'
Bisecting: 1 revision left to test after this (roughly 1 step)
[6def9f5...] Fix typo in error message
running '../check-total.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[a3421fa...] Tidy imports
running '../check-total.sh'
68ece1f24257159963d318cc9992d1e228b4ae48 is the first bad commitgit show 68ece1f -- price.js shows the one-line cause: someone wrapped the price in Math.round() before multiplying, so 19.99 became 20. Notice the second step. The midpoint was a work-in-progress commit that doesn’t parse, the script returned 125, and the remaining count went from 7 to 6 instead of halving. Bisect picked a neighbour and carried on. git bisect log records it as # skip: [e603bc3...] WIP: split checkout module.
What happens when “doesn’t build” counts as bad
Here’s the same search with a simpler script that only has the second step. When the WIP commit fails to load, Node exits with status 1, and bisect hears “bad”:
$ git bisect run ../naive-check.sh
Bisecting: 7 revisions left to test after this (roughly 3 steps)
[e603bc3...] WIP: split checkout module
running '../naive-check.sh'
SyntaxError: Unexpected end of input
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[2062e69...] Bump eslint to 9.12
...
e603bc368db482bf747dca0cc0b081ed55108022 is the first bad commitBisect confidently names the WIP commit. It’s wrong: that commit has nothing to do with rounding. Once the first answer was a lie, every later step searched the wrong half, and the final answer looks just as authoritative as a correct one. This is the failure interviewers want you to describe. A script that can’t tell “the bug is here” from “I couldn’t run the test” will blame the first broken build it meets.
So separate the two. Build or install first, and map any failure there to 125. Only the symptom check gets to return 0 or 1. If a large stretch of history is unbuildable, bisect may end by listing several candidate commits it couldn’t distinguish. That’s an honest result; report it as a range.
Making the check deterministic
Before starting, run the script at both endpoints, more than once. In the demo it returned 1 1 1 on main and 0 0 0 on v1.4.0. If either end is inconsistent, bisect will be too.
Things that make a check unreliable:
- A flaky test. If the failure shows up one run in ten, a single passing run on a bad commit marks it good. Either find a deterministic reproducer (a fixed seed, a fixed clock, a single test instead of the suite), or have the script run the check enough times that a pass means something. Repeating 20 times turns a 10% failure rate into roughly an 88% chance of catching it at least once, which still isn’t certainty, so say so when you report the result.
- State outside the commit. A stale
node_modules, a build cache, a local database or an environment variable can make an old commit behave like a new one. Reinstall dependencies in the script if lockfiles changed across the range, and clean build outputs. - A different symptom. If the old code fails for a different reason (a missing fixture, an API that didn’t exist yet), it’ll be marked bad. Make the check test exactly the behaviour that regressed.
Useful options
git bisect skip <rev>marks a commit untestable by hand.git bisect start --first-parentfollows only the first parent of merges, so on a main branch built from merged pull requests it finds the merge that introduced the bug instead of descending into a feature branch’s half-finished commits.git bisect termsand--term-old=fast --term-new=slowlet you search for something that isn’t a bug, like “when did this page get slow?” or “when was this behaviour fixed?”git bisect log > bisect.txtandgit bisect replay bisect.txtlet you restart a session after you notice you mis-marked one step, without redoing the steps before it.
Interview practice
An integration test started failing on main some time in the last 300 commits. It fails about one run in five, and roughly 40 of those commits don’t compile because of a dependency upgrade that was fixed a few commits later. A colleague ran git bisect run npm test and got a commit that only touched documentation. What went wrong, and how would you redo it?
Answer
Two classification errors compounded. First, npm test fails when the project doesn’t compile, so any of the 40 broken commits that bisect visited was marked bad. Second, the test is flaky: a bad commit has about an 80% chance of passing a single run, so bisect probably marked some bad commits good. Either error sends the search into the wrong half, and a docs-only commit is a typical result of that.
To redo it, write a script outside the repo that installs dependencies and builds, exiting 125 if either step fails. Then run only the failing integration test, not the whole suite, and repeat it. With a 20% failure rate, 15 runs gives about a 96% chance of seeing at least one failure on a bad commit, so the script can exit 1 on any failure and 0 only if all 15 pass. Run the script several times on the known-good and known-bad endpoints first. Consider --first-parent so the search stays on merge commits, which skips most of the broken mid-branch states anyway. Finally, check the answer: revert the reported commit on top of main and confirm the test passes repeatedly.