Skip to content
Go back

git bisect: Find the Commit That Broke It

By KingPin 13 min read
git bisect: Find the Commit That Broke It
Contents

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 rangeTests needed
1007
1,00010
10,00014
100,00017

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.

Terminal window
./make-demo-repo.sh ./bisect-demo
cd bisect-demo
Created 100 commits in ./bisect-demo

The app is a tiny bash function:

app.sh
#!/usr/bin/env bash
# total <unit_price> <qty>: prints the line total
total() {
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:

Terminal window
git bisect start HEAD v1.0
Bisecting: 49 revisions left to test after this (roughly 6 steps)
[ea69208a8cc9e0067fe3b0d0df7995f33b78a335] change 50

Git 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:

Terminal window
git bisect good
Bisecting: 24 revisions left to test after this (roughly 5 steps)
[a511a0e850da3b2cbce5ea96060281fa9a84b2d1] change 75

Now commit 75. It’s broken:

Terminal window
git bisect bad
Bisecting: 12 revisions left to test after this (roughly 4 steps)
[cc5c6f56d5487c4b39235442cd4898c0051cb753] wip: refactor total() part 2

Keep 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:

Terminal window
git bisect reset
Previous HEAD position was 9bcde0c change 69
Switched 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.

Terminal window
git bisect skip
Bisecting: 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 codeMeaning
0Good (old)
1 to 127, except 125Bad (new)
125Can’t test this commit, skip it
Anything else (128 and up)Abort the whole bisect

The script from the kit:

test.sh
#!/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 verdict

bash -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:

Terminal window
git bisect start HEAD v1.0
git bisect run "$OLDPWD/test.sh"
f96d30b004f965b6780d3aa1871bfe0ca9644eb0 is the first 'bad' commit
commit f96d30b004f965b6780d3aa1871bfe0ca9644eb0
Author: 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' commit

Eight 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 >= 128

A 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' revision

Good. 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:

Terminal window
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:

Terminal window
git bisect start
git bisect old v1.0
git bisect new
status: waiting for both 'good' and 'bad' commits
status: waiting for 'new' commit, 1 'old' commit known
Bisecting: 49 revisions left to test after this (roughly 6 steps)
[ea69208a8cc9e0067fe3b0d0df7995f33b78a335] change 50

Notice 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:

Terminal window
git bisect start --term-old slow --term-new fast
git bisect terms
status: waiting for both 'slow' and 'fast' commits
Your current terms are 'slow' for the old state
and '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:

Terminal window
git bisect log
# bad: [5d088083c73b787eb835568dabf7b62221d8ac14] change 100
# good: [e9da161fe1f4a3c07d5c52be22a10879cd622f31] change 1
git bisect start 'HEAD' 'v1.0'
# good: [ea69208a8cc9e0067fe3b0d0df7995f33b78a335] change 50
git bisect good ea69208a8cc9e0067fe3b0d0df7995f33b78a335
# bad: [a511a0e850da3b2cbce5ea96060281fa9a84b2d1] change 75
git bisect bad a511a0e850da3b2cbce5ea96060281fa9a84b2d1
# skip: [cc5c6f56d5487c4b39235442cd4898c0051cb753] wip: refactor total() part 2
git bisect skip cc5c6f56d5487c4b39235442cd4898c0051cb753

Save that to a file, delete the line where you fat-fingered good, and replay it:

Terminal window
git bisect log > bisect.log
# edit bisect.log
git bisect reset
git bisect replay bisect.log

Git 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:

Terminal window
git bisect start --first-parent HEAD good-tag

I 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:

Terminal window
git bisect view --oneline

It 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.sh
Please commit your changes or stash them before you switch branches.
Aborting

Commit 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.

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:

flaky-test.sh
for i in 1 2 3 4 5 6 7 8 9 10; do
./run-the-test || exit 1
done

This 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Recording Rules: Faster Prometheus Panels

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts