The test suite has been green for three weeks. Today npm test fails on an assertion that has nothing to do with what you just changed, and git log v1.4.0..HEAD shows 340 commits from four people since the last release anyone is sure worked. Reading every diff costs you a day. Checking out commits at random is faster and still not a plan.

git bisect turns that into a binary search. You give it one commit you know is good and one you know is bad; it checks out the commit halfway between them and waits while you test that single commit and report back. Each answer halves the range. 340 commits resolve in at most 9 tests, since 2^9 = 512.

How git bisect narrows the search by half each time

Picture the 340 commits in a line, oldest to newest, the known-good tag at one end and the broken HEAD at the other. git bisect doesn’t walk that line. It jumps to the middle, waits for a verdict, then throws away whichever half the verdict rules out:

1
2
3
4
good                                          bad
 |------------------------|------------------|
                    ^
              test this one

Say the middle commit passes. The bug is in the newer half, so the older half is gone for good and you never see those commits again in this session. Repeat: middle of what’s left, test, discard half. After roughly log2(n) rounds the range is a single commit — the one where the test went from passing to failing. That is not always the commit that “caused” the bug in some deeper sense. It’s the commit where the behavior changed, which is almost always what you actually need.

How to start a git bisect session

Run this from a clean working tree. Bisect checks out commits under you, and it refuses to start over uncommitted changes.

1
2
3
git bisect start
git bisect bad HEAD
git bisect good v1.4.0

Git checks out the midpoint and tells you how much is left:

1
2
Bisecting: 169 revisions left to test after this (roughly 8 steps)
[a1b2c3d4e5f6...] Refactor payment retry logic

Run your test at that commit, then report what you found:

1
2
npm test
git bisect bad     # test failed here

or

1
git bisect good    # test passed here

Git checks out the new midpoint automatically after each answer. Keep testing and reporting until there’s nothing left to narrow:

1
2
3
4
5
6
a1b2c3d4e5f6789... is the first bad commit
commit a1b2c3d4e5f6789...
Author: Priya Nair <[email protected]>
Date:   Tue Sep 2 11:14:02 2026 +0000

    Refactor payment retry logic

That’s your answer. Read that diff, not the eight commits around it. git bisect reset puts you back on the branch and commit you started from, and the checkouts bisect made along the way leave nothing behind.

1
git bisect reset

Automating the loop with git bisect run

Answering good or bad by hand is fine for a bug you can spot in a few seconds. For anything that needs a real test run, hand the whole loop to a script:

1
2
3
4
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
git bisect run ./bisect-test.sh

bisect-test.sh only has to exit with the right code: 0 means good, any code from 1 to 127 except 125 means bad, and 125 means “can’t test this commit, skip it”.

1
2
#!/bin/sh
npm test

Most test runners already exit 0 on pass and non-zero on failure, so a single runner call is often the whole script. git bisect run checks out each midpoint, runs the script, reads the exit code, and reports the result itself, printing the same “first bad commit” summary at the end.

The script can do more than call a test runner. If the regression only shows up in a built artifact, a script that runs docker build and then exercises the image is a normal thing to hand to git bisect run — the build steps from Build and Push a Docker Image with GitHub Actions work locally in a script as well as they do in a workflow. Budget for it: every round now costs a full image build.

One constraint applies to the script itself. Keep it outside the range you’re bisecting, or make sure it hasn’t changed across that range. If the script lives in the history being searched, an old commit checks out an old version of the script along with the old code, and you’re no longer running the same test at every step.

Skipping commits you can’t test with git bisect skip

Some commits can’t be tested on their own: a mid-refactor commit that doesn’t build, a schema migration that needs a database state you don’t have. Forcing a good/bad verdict on one of those poisons the result. Leave it out instead:

1
git bisect skip

Bisect checks out a nearby commit and continues around the gap. In bisect run, exit code 125 does the same thing automatically, so a script that can tell “this commit doesn’t even compile” can skip rather than report a false bad. Skip too many adjacent commits and bisect runs out of testable ground: the final answer becomes “the bug came from one of these N commits” instead of a single one.

When git bisect isn’t the right tool

Bisect assumes your test is deterministic: same commit, same result, every time. A flaky test breaks that assumption silently. Bisect can’t tell “this commit is bad” from “this test flaked”, and one wrong answer early sends every later round into the wrong half. Fix or exclude the flaky assertion before you bisect, not after.

It also assumes one commit produced one clean behavior change. A bug that crept in across several commits, or one that depends on accumulated state rather than a single diff, won’t converge on anything useful. Heavily rebased history is a milder version of the same problem (see Git Rebase vs Merge): the first bad commit bisect finds may be a squashed commit bundling five logical changes. Correct, but too coarse to review in isolation.

Neither case calls for a different tool, only a different starting point. Get the test reliable first, then pick a good commit far enough back that you’re sure the bug wasn’t already there.

The next time a regression turns up with no obvious cause, mark the two endpoints and answer good or bad a handful of times — or write the three-line script and let git bisect run do the answering.