Finding Regressions with git bisect
Binary-search your history to the exact commit that broke something, then automate it with git bisect run, the exit-code contract, skip, terms, and replay.
Binary-search your history to the exact commit that broke something, then automate it with git bisect run, the exit-code contract, skip, terms, and replay.
The bug is real, the test proves it, and the last release was fine. Somewhere in the two thousand commits since then, one of them broke checkout. Reading them all is not a plan.
Git can find that commit for you in about eleven tests. By the end of this lesson you will run a bisect by hand, hand the whole search to a script so it finishes while you are in a meeting, and know which kinds of history make the technique work.
You already know how to find a word in a dictionary: open the middle, decide which half the word is in, throw the other half away, repeat. Bisecting is that same move applied to commits. Git checks out a commit halfway between a known-good one and a known-bad one, you say which side it landed on, and half the remaining history disappears.
The arithmetic is the whole reason this matters. Each answer halves the range, so the number of tests is the base-2 logarithm of the range size. A thousand commits is ten tests. Ten thousand commits is about fourteen. A hundred thousand is seventeen. Doubling your project's history costs you exactly one more test — your effort stays almost flat while the haystack grows without limit.
Start by telling Git you are searching, then give it the two
endpoints. HEAD is broken; the v4.2.0 tag was the last
release nobody complained about:
git bisect start
git bisect bad # no argument means "HEAD is bad"
git bisect good v4.2.0With one bad and one good endpoint, Git makes the first jump:
Bisecting: 987 revisions left to test after this (roughly 10 steps)
[8f2c1ab9d3e7c05a1b6f4d2e9c8a7b3f5d1e0c92] Cache tax rates per regionYour working tree is now that commit. Build it, run the failing
scenario, and report back with git bisect good or git bisect bad — no hash needed, Git knows where you are. Each answer
prints a new midpoint and a smaller step count. Repeat until the
count reaches zero:
$ git bisect bad
3d9b7e14c8a2f6b09d5e3c17a4b8f2e6d0c9a1b7 is the first bad commit
commit 3d9b7e14c8a2f6b09d5e3c17a4b8f2e6d0c9a1b7
Author: Priya Raman <priya@example.com>
Date: Tue Mar 4 11:02:13 2025 +0000
Normalise discount codes before lookup
src/checkout/discount.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)That is the answer: a hash, a subject line, and the files it touched. Now end the session and return to where you started:
Once this is familiar, collapse the opening into one line.
git bisect start <bad> <good> takes both endpoints and jumps
straight to the first midpoint:
Sometimes the midpoint will not build, or the feature you are testing did not exist yet. Calling it good or bad is a lie that sends the search down the wrong half. Say so instead:
Git picks a different commit near the same point and carries on. If you already know a whole stretch is unbuildable — a week-long refactor, say — skip the range in one go:
If too much gets skipped, Git gives up honestly with "there are only skipped commits left to test": the culprit is inside the untestable region and bisect cannot narrow it further. Widen what counts as a test, fix the build there, or read the remaining handful of diffs by hand.
Everything so far is the manual version, and the manual version
is the one to stop doing. If you can express "is this commit
broken?" as a command that exits non-zero on failure,
git bisect run performs the whole search unattended:
Git checks out a commit, runs your command, reads the exit code,
marks the commit, moves on — dozens of build-and-test cycles
with no input from you, ending in the same is the first bad commit report.
The contract is the exit code, and it is worth memorising:
A bare npm test rarely captures the real question. Write a
small script that installs, builds, runs exactly one test, and
maps every outcome onto the right exit code:
Make it executable and hand it over:
"Good" and "bad" are only labels, and they fit badly when you
ask when a page got slow or when a half-finished feature first
worked. Rename them with --term-old and --term-new at start
time:
From then on you answer with git bisect broken and git bisect fixed, and git bisect terms reminds you which pair is in
force. For a performance hunt, --term-old fast --term-new slow
reads the way you think, and pairs well with a run script that
fails when a benchmark crosses a threshold.
A bisect session is a sequence of decisions, and Git keeps a transcript you can save and replay:
Hand that file to a colleague, or use it after a mistake: delete
the wrongly marked line from the log, reset, and replay — far
quicker than starting over. To see the range still under
suspicion, run git bisect visualize (or view), which opens
gitk when a display is available and falls back to git log
otherwise. It takes log options, so git bisect visualize --oneline works well over SSH.
You can also narrow what Git considers in the first place. Pass
paths after -- and only commits touching them are candidates:
On a monorepo this can cut two thousand candidates to eighty — seven tests instead of eleven. Use it only when you are sure the bug lives in that subtree; a change elsewhere that broke checkout is invisible to this search.
Real history is not a straight line, and bisect does not need one. It works over the commit graph, picking the commit that best splits the set reachable from bad but not from good — a set that includes merged branches. So bisect will happily check out a commit that only ever existed on someone's feature branch.
This is where the commit hygiene from the intermediate course pays a dividend you can measure.
Every one is a halving.
Ten steps over a thousand commits. And a small, self-contained commit means the culprit you get back is a twenty-line diff you can read.
Every one is a skip.
A skip widens the uncertainty rather than narrowing it, and a run of them can leave you with a range instead of an answer.
Sends the search into the wrong half.
One spurious failure marks a good commit bad, and every step after that is wasted. Require several consecutive failures in the script before calling a commit bad.
Start by reading it. The hash on its own is not the point; the diff is:
Nine times out of ten the mistake is obvious once you see it in isolation. If you need the pressure off now, undo it with a new commit rather than rewriting anything:
Then close the loop: write a test that fails on the bad commit and passes with the fix, and commit it alongside. A bisect that ends without a regression test means the next person runs the same search again.
Bisect answers "when did behaviour change?" Other questions have cheaper answers.
When you know the text involved — a function name, a config
key, a magic string — reach for the pickaxe. git log -S
finds commits that changed how many times a string appears, so
it locates where something was introduced or deleted; -G takes
a regex and matches any diff touching it:
When you care about one stretch of one file, git log -L gives
you the history of those lines alone, diff by diff, following
the code as it moves:
And when you only want to know who last touched a line, git blame is instant where bisect takes minutes. Use blame to form
a hypothesis; use bisect when blame points at a line that looks
perfectly innocent.
You can now turn a bug report and a release tag into a commit hash, whether the gap between them is fifty commits or fifty thousand — and hand that search to a script while you get on with something else.
The natural next step is rewriting history at scale — git rebase in its more powerful forms and git filter-repo,
reshaping thousands of commits at once. That lesson is the other
half of this one: bisect rewards a clean, always-building
history, and rewriting is how you produce one.
The best practice is a real hunt. Take a repository you know,
pick a behaviour that changed at some point, and bisect for it
twice: once by hand to feel the halving, and once with a run
script to see how little of your attention it costs the second
time.
git bisect resetgit bisect start HEAD v4.2.0git bisect skipgit bisect skip v4.3.0..v4.3.4 # exclusive of v4.3.0, inclusive of the tipgit bisect start HEAD v4.2.0
git bisect run npm test -- -t "checkout total"#!/usr/bin/env bash
# /tmp/bisect-checkout.sh — deliberately outside the repository
set -u
# A broken build says nothing about the bug: skip, do not blame.
npm ci --silent || exit 125
npm run build || exit 125
# This test flakes about one run in twenty. Only call the commit
# bad if it fails every attempt.
for _ in 1 2 3; do
npm test --silent -- -t "checkout total" && exit 0
done
exit 1chmod +x /tmp/bisect-checkout.sh
git bisect start HEAD v4.2.0
git bisect run /tmp/bisect-checkout.shgit bisect start --term-old broken --term-new fixed
git bisect fixed # it works today
git bisect broken v4.2.0 # it did not work at the releasegit bisect log > /tmp/checkout-hunt.log
git bisect replay /tmp/checkout-hunt.loggit bisect start HEAD v4.2.0 -- src/checkout/ src/pricing/git show 3d9b7e1git revert 3d9b7e1git log -S "MAX_DISCOUNT" --oneline
git log -G "parseFloat\(.*discount" --onelinegit log -L 40,68:src/checkout/discount.tsgit bisect start # begin a session
git bisect bad # current commit is broken
git bisect good v4.2.0 # this ref was fine
git bisect start HEAD v4.2.0 # both endpoints in one line
git bisect skip # cannot judge this commit
git bisect skip v4.3.0..v4.3.4 # skip a whole range
git bisect run <cmd> # automate: 0 good, 125 skip
git bisect run /tmp/check.sh # script must live outside the repo
git bisect start --first-parent # mainline commits only
git bisect start HEAD v4.2.0 -- src # limit to a subtree
git bisect terms # show the current term pair
git bisect log > /tmp/hunt.log # save the session
git bisect replay /tmp/hunt.log # restore or share it
git bisect visualize --oneline # see what is still suspect
git bisect reset # end and return to your branch
git show <hash> # read the guilty diff
git revert <hash> # undo it with a new commit
git log -S "STRING" --oneline # when a string appeared or left
git log -G "regex" --oneline # diffs matching a pattern
git log -L 40,68:path/to/file.ts # history of a line range
git blame path/to/file.ts # who last touched each line