It Worked Last Month. Now It Doesn’t.
You have 100 commits since the last time the thing worked. Somewhere in there, one of them broke it. You could read all 100 diffs. You could blame the intern. Or you could let git binary-search the history and hand you the guilty commit in about seven tests.
git bisect does exactly that. You tell it one commit that’s good and one that’s bad, and it checks out the midpoint. You test, say good or bad, and it halves the range again. Add a script and the whole thing runs without you. Your 2 AM self will thank you.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/devops/git-bisect-find-breaking-commit
The Math: Why Seven Tests Beats One Hundred
Each test cuts the suspect range in half. So the number of tests is the base-2 log of the commit count, rounded up. I checked these with math.log2:
| Commits in range | Tests needed |
|---|---|
| 100 | 7 |
| 1,000 | 10 |
| 10,000 | 14 |
| 100,000 | 17 |
Going from a thousand commits to a hundred thousand costs you seven more tests. That’s why bisect is the first tool to reach for when a regression hides somewhere in a long history.
The Demo Repo
The companion kit builds a throwaway repo so you can follow along. It has 100 deterministic commits. One of them, “speed up total() calculation,” changes a function so it returns the wrong value. Two earlier commits are work-in-progress junk that doesn’t even parse, which we’ll need later.
./make-demo-repo.sh ./bisect-democd bisect-demoCreated 100 commits in ./bisect-demoThe app is a tiny bash function:
#!/usr/bin/env bash# total <unit_price> <qty>: prints the line totaltotal() { echo $(( $1 * $2 ))}total 3 4 should print 12. At HEAD it doesn’t. The script tags the very first commit v1.0, and we know that one worked.
Manual Bisect: Good, Bad, Repeat
Start the session by naming the bad commit and the good one:
git bisect start HEAD v1.0Bisecting: 49 revisions left to test after this (roughly 6 steps)[ea69208a8cc9e0067fe3b0d0df7995f33b78a335] change 50Git checked out commit 50 of 100. Test it however you test. In our case that’s sourcing the script and calling total 3 4. It works, so:
git bisect goodBisecting: 24 revisions left to test after this (roughly 5 steps)[a511a0e850da3b2cbce5ea96060281fa9a84b2d1] change 75Now commit 75. It’s broken:
git bisect badBisecting: 12 revisions left to test after this (roughly 4 steps)[cc5c6f56d5487c4b39235442cd4898c0051cb753] wip: refactor total() part 2Keep going until git prints <hash> is the first 'bad' commit, followed by the commit message, author, and diffstat. Then clean up. This output is from the end of a finished session:
git bisect resetPrevious HEAD position was 9bcde0c change 69Switched to branch 'main'reset puts you back on the branch you started from. Forget it and you’ll spend ten confused minutes wondering why your shell prompt says detached HEAD.
When a Commit Won’t Build: git bisect skip
Look at the last step above. Git landed on “wip: refactor total() part 2.” That commit has a syntax error. You can’t call it good or bad, because the code doesn’t run at all. Marking it either way would poison the search.
git bisect skipBisecting: 11 revisions left to test after this (roughly 4 steps)[3f66a92140d41ce827ce882d914a7ee62e772c5b] finish refactor of total()Git picks a nearby commit instead. The man page warns about one limit: if you skip a commit right next to the culprit, git can’t tell which of the adjacent commits was the first bad one. Expect a range of suspects instead of one commit.
You can also skip a range with git bisect skip v2.5..v2.6. To skip the first commit of that range too, name it separately: git bisect skip v2.5 v2.5..v2.6.
Automate It: git bisect run
Clicking good and bad 7 times is fine. Doing it 14 times, at 2 AM, with a slow build, is how people start questioning their career. Write a script and let git drive.
The rules, straight from man git-bisect:
| Exit code | Meaning |
|---|---|
| 0 | Good (old) |
| 1 to 127, except 125 | Bad (new) |
| 125 | Can’t test this commit, skip it |
| Anything else (128 and up) | Abort the whole bisect |
The script from the kit:
#!/usr/bin/env bash# Run from inside the repo being bisected. Keep this file OUTSIDE the repo.# exit 0 = good, 1 = bad, 125 = cannot test (skip)bash -n app.sh 2>/dev/null || exit 125 # does not parse: skip
source ./app.sh[ "$(total 3 4)" = "12" ] # exit status of this test is the verdictbash -n parses without running. If app.sh is broken, the script exits 125 and git skips the commit. Otherwise the last line is a [ test whose exit status is 0 when the answer is 12 and 1 when it isn’t. The shell hands that status back to git.
You’re inside bisect-demo now, so $OLDPWD points back at the kit directory where test.sh lives:
git bisect start HEAD v1.0git bisect run "$OLDPWD/test.sh"f96d30b004f965b6780d3aa1871bfe0ca9644eb0 is the first 'bad' commitcommit f96d30b004f965b6780d3aa1871bfe0ca9644eb0Author: Demo <[email protected]>Date: Sat Jan 3 18:00:00 2026 +0000
speed up total() calculation
CHANGELOG | 1 + app.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-)bisect found first 'bad' commitEight test runs, one of them skipped, and git named the right commit. The “speed up” commit turned $1 * $2 into $1 * ($2 - 1). Classic.
If you don’t want to run git bisect reset by hand, pass --reset-when-found to start or to run. I ran it with run: the bisect state was gone afterward and I was back on main. Add =found to leave the bad commit checked out instead.
Exit codes bite when the script is wrong
I ran a few deliberately wrong scripts to see what git does. A script that exits 255 aborts the run:
error: bisect run failed: exit code 255 from 'sh' '-c' 'exit 255' is < 0 or >= 128A script that exits 127, which is what a typo in the command name gives you, fails differently. When the first run returns 126 or 127, git re-runs the script on your good commit. If that returns the same code, git stops:
error: bogus exit code 127 for 'good' revisionGood. A script that fails the same way on the known-good commit is broken, and git refuses to continue instead of handing you a confident wrong answer. That check only covers 126 and 127. A script that exits 1 everywhere sails through and blames the first commit after your good one.
Docker Angle: Bisecting a Build
The same pattern works when the test is “does the image build and the container pass its check.” The man page example for builds is make || exit 125. For Docker, swap in the build:
git bisect run sh -c 'docker build -q -t bisect-test . || exit 125; docker run --rm bisect-test ./check.sh'A build that fails is a skip, because a broken Dockerfile in a WIP commit says nothing about your regression. The container’s own exit code becomes the verdict. I checked it on a five-commit toy repo: a commit with a broken RUN line got skipped, and check.sh decided the rest. One trap: if check.sh is missing from the image, docker run exits 127 and every commit counts as bad. Layer caching makes repeated builds cheap, but see the stale cache gotcha below.
Finding When Something Got Fixed (or Got Faster)
“Good” and “bad” are awkward when you’re hunting for the commit that fixed a bug or sped up a query. Saying “the bad commit is the fixed one” melts your brain. Use old and new instead:
git bisect startgit bisect old v1.0git bisect newstatus: waiting for both 'good' and 'bad' commitsstatus: waiting for 'new' commit, 1 'old' commit knownBisecting: 49 revisions left to test after this (roughly 6 steps)[ea69208a8cc9e0067fe3b0d0df7995f33b78a335] change 50Notice that you start with a bare git bisect start and no commits. The man page says that’s required for the old/new terms. Don’t mix vocabularies: once you’re in an old/new session, git bisect good fails with error: Invalid command: you're currently in a new/old bisect.
You can invent your own words too:
git bisect start --term-old slow --term-new fastgit bisect termsstatus: waiting for both 'slow' and 'fast' commitsYour current terms are 'slow' for the old stateand 'fast' for the new state.Now git bisect slow and git bisect fast are your commands. If you forget which is which at hour three, git bisect terms tells you.
Log, Replay, and Fixing Your Own Mistakes
You will mark a commit wrong at some point. Everyone does. The bisect log records every decision:
git bisect log# bad: [5d088083c73b787eb835568dabf7b62221d8ac14] change 100# good: [e9da161fe1f4a3c07d5c52be22a10879cd622f31] change 1git bisect start 'HEAD' 'v1.0'# good: [ea69208a8cc9e0067fe3b0d0df7995f33b78a335] change 50git bisect good ea69208a8cc9e0067fe3b0d0df7995f33b78a335# bad: [a511a0e850da3b2cbce5ea96060281fa9a84b2d1] change 75git bisect bad a511a0e850da3b2cbce5ea96060281fa9a84b2d1# skip: [cc5c6f56d5487c4b39235442cd4898c0051cb753] wip: refactor total() part 2git bisect skip cc5c6f56d5487c4b39235442cd4898c0051cb753Save that to a file, delete the line where you fat-fingered good, and replay it:
git bisect log > bisect.log# edit bisect.loggit bisect resetgit bisect replay bisect.logGit re-applies your decisions and lands you at the next commit to test. A replayed session that’s already complete just prints the result again, which is also a handy way to share a bisect with a coworker.
Merge-Heavy History: --first-parent
Feature branches with messy WIP commits make bisect noisy. The branch might contain commits that don’t build, and your test lands on them. If the question is “which merge brought this in,” follow only the first parent:
git bisect start --first-parent HEAD good-tagI built a small repo to check: main merges a feature branch whose fourth commit adds a file that breaks the test. A plain bisect blamed “feat 4 breaks,” the exact feature commit. With --first-parent, bisect blamed Merge feat instead. Per the man page, the merge commit is identified as the introduction and its ancestors are ignored.
Use plain bisect when you want the exact line. Use --first-parent when the branch has junk commits or you only care which PR to revert.
Look Before You Leap: view
git bisect visualize (or view) opens gitk if you have a graphical display, and falls back to git log otherwise. It takes log options, so this works fine in a terminal:
git bisect view --onelineIt shows the commits still in the suspect range. Handy for deciding whether to skip, or just to see how much is left.
The Gotchas That Waste Your Afternoon
Keep the test script outside the repo
Every checkout swaps your working tree to a different commit. If test.sh is a tracked file, git replaces it with the old version, or deletes it, or the commit you’re testing predates it entirely. Put it somewhere outside the repo, or keep it untracked, and call it by absolute path. That’s why the kit uses "$OLDPWD/test.sh".
A dirty working tree stops the bisect
I edited app.sh, left it uncommitted, then marked a commit. Git refused to check out the next one:
error: Your local changes to the following files would be overwritten by checkout: app.shPlease commit your changes or stash them before you switch branches.AbortingCommit or stash before you start.
Stale build artifacts lie to you
You bisect, the test builds, and an object file from three commits ago gets reused. Now every verdict is a coin flip. Clean between steps: git clean -fdx inside the script, or build in a fresh directory. With Docker, --no-cache is slower but honest. If your test depends on a build, put the build inside the test script.
Flaky tests poison the search
Bisect assumes the test is deterministic. One flaky “bad” at step 2 sends you into the wrong half of history, and the search confidently converges on an innocent commit. If the failure is intermittent, loop it in the script:
for i in 1 2 3 4 5 6 7 8 9 10; do ./run-the-test || exit 1doneThis marks a commit bad if any of ten runs fails. A flake that fails 10% of the time still slips through about a third of the time, so raise the count or fix the test first. The loop has a second failure mode: if good commits flake 10% of the time too, ten runs mark a good commit bad about 65% of the time. A test that passes on a coin flip is a lottery ticket, not a bisect oracle.
Verify the endpoints first
Before the first real step, run your script on the commit you called good and on the one you called bad. If it disagrees with you, the search is meaningless. Git only double-checks the 126 and 127 case, so this one is on you.
Common Questions
Can git bisect find a commit that fixed a bug?
Yes. Run git bisect start, mark a commit from before the fix with git bisect old <commit>, and mark one after it with git bisect new. Bisect then reports the first “new” commit. You can also pick your own words with --term-old and --term-new.
How many steps does git bisect need?
The step count is log2 of the commits in the range, rounded up. A range of 100 commits needs 7 tests, 1,000 needs 10, and 100,000 needs 17. Git prints a “roughly N steps” estimate after each answer. On my 100-commit run it estimated 6 steps at the start.
Does git bisect work on a shallow clone?
Only inside the history you cloned. A shallow clone has no commits past its boundary, so bisect cannot look further back. In my test with --depth 20, marking the oldest fetched commit as good made bisect blame the commit right after that boundary. Run git fetch --unshallow first.
Can I run git bisect with uncommitted changes?
Not if the changes touch files that differ between the commits bisect checks out. Git aborts with “Your local changes to the following files would be overwritten by checkout.” Commit or stash the changes first, and keep your test script outside the repo.