Your CI is red on main, but it was green yesterday. Somewhere between then and now, a bug crept in. You could scroll through 200 commits, reading diffs and guessing. You could ask in Slack and hope someone remembers touching the relevant code. Or you could let Git do the work for you.
git bisect is a binary search tool for your commit history. You mark one commit as bad and one as good. Git checks out the midpoint. You test it, mark it good or bad, and Git repeats. In 8 steps, it can isolate a single commit out of 256. In 12 steps, it can find the needle in 4,096. It is the closest thing Git has to a debugger for history itself.
Why manual bisection is a waste of your time
Developers already bisect manually without realizing it. You check out an older commit, run the tests, and think “still broken, gotta go further back.” Then you check out an even older one, and the tests pass. Now you know the bug is somewhere between those two points. So you pick one in the middle and keep narrowing.
That is binary search. git bisect automates the bookkeeping so you do not lose track of which commits you have already tested. More importantly, it prevents the lazy heuristic of “it was probably in that big refactor from Tuesday” that sends you down the wrong rabbit hole for two hours.
The real value is not speed, though it is faster. The real value is correctness. When you are frustrated and hunting a bug at 6 PM, you will make mistakes. You will forget to rebuild after checkout. You will test the wrong commit twice. You will misread a test result and mark a bad commit as good, destroying your search space. git bisect enforces discipline you do not have when you are annoyed.
How the binary search actually works
Git does not search chronologically. It searches topologically, walking the commit graph to find the midpoint between your known-good and known-bad commits. This matters when your history has merges, because the chronological midpoint might not even be reachable from both endpoints.
Here is what happens under the hood. You start the bisect and provide bounds:
git bisect start
git bisect bad HEAD # current commit is broken
git bisect good v2.1.0 # this release was fine
Git computes the number of commits between those two points. It checks out the one exactly in the middle and waits for you to test it. You run your reproduction, see if the bug is present, and tell Git:
git bisect bad # this commit has the bug
git bisect good # this commit is clean
Git discards half the search space and repeats. When only one commit remains, it stops and shows you the first bad commit. The output includes the commit hash, author, date, and message. There is no ambiguity. This commit introduced the bug, full stop.
A concrete walkthrough with real commands
Suppose your integration tests started failing this morning. You know they passed in the last tagged release, v1.4.0. Here is the full session:
# Start the session
git bisect start
# Mark the current HEAD as bad
git bisect bad HEAD
# Mark the last known good release
git bisect good v1.4.0
# Git checks out a midpoint commit automatically
# You run your reproduction script or test suite:
npm test -- --grep "checkout flow"
# Tests fail. Mark it bad.
git bisect bad
# Git checks out another midpoint. Run tests again.
npm test -- --grep "checkout flow"
# Tests pass. Mark it good.
git bisect good
# Repeat until Git tells you:
# "<commit-hash> is the first bad commit"
At the end, Git leaves you on the bad commit. You can inspect it with git show, create a fix, and then clean up:
git bisect reset
This returns you to the branch you were on before you started. If you forget to reset, you will stay on a detached HEAD and wonder why your next commit is not on your branch. I have done this. It is embarrassing.
Automating the entire process with a script
The manual version still requires you to run tests and type good or bad repeatedly. If your reproduction is a single command that exits 0 for success and non-zero for failure, you can hand the whole thing to Git:
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# Hand over control to an automated script
git bisect run npm test -- --grep "checkout flow"
Git will check out commits, run your command, and classify results automatically. When it finishes, you get the same “first bad commit” output without touching your keyboard. This is where git bisect goes from useful to indispensable.
Your script does not need to be a test suite. It can be anything executable that returns meaningful exit codes. Here is a minimal shell script that checks for a specific log message:
#!/bin/bash
# reproduce-bug.sh
# Exit 0 if the bug is NOT present (good)
# Exit 1 if the bug IS present (bad)
if curl -s http://localhost:3000/api/health | grep -q "database_timeout"; then
exit 1 # bug is present
fi
exit 0 # bug is not present
Run it:
chmod +x reproduce-bug.sh
git bisect run ./reproduce-bug.sh
The exit code contract is strict. Exit 0 means “good,” exit 1 through 124 means “bad,” and exit 125 means “skip this commit, it is untestable.” Exit 125 is useful when a commit does not compile, or the server cannot start because of an unrelated configuration change. Git will skip that commit and search around it.
Where bisect breaks down: the gotchas
git bisect assumes your bug is monotonic. Once a commit introduces it, every descendant commit is also bad. If the bug flickers, appearing and disappearing across commits, binary search falls apart. You will get nonsense results or Git will complain that it cannot isolate a single commit.
Non-deterministic bugs are the worst offenders. A race condition that fails 10% of the time will occasionally mark a bad commit as good, corrupting the search. If your reproduction is flaky, fix the flakiness first. Or run the test multiple times in your script and only mark a commit good if it passes every run. This is slower but more reliable.
Build artifacts are another trap. If you switch from a commit that changes a build tool version to one that does not, your stale node_modules or compiled binaries might not match the checked-out code. Always clean and rebuild inside your reproduction script if your project has a build step.
#!/bin/bash
# safer-reproduce.sh
rm -rf node_modules dist
npm ci
npm run build
npm test -- --grep "checkout flow"
This adds a few seconds per iteration, but it eliminates an entire category of false positives where the bug is actually in stale artifacts.
What bisect does not do
git bisect finds the commit. It does not tell you why the commit is bad. A 5,000-line refactor that touches forty files can be the first bad commit, and you still have to read the diff to understand which line is the culprit. Bisect narrows your scope from “somewhere in the last month” to “somewhere in this diff.” The rest is still your job.
It also does not help with bugs that exist in the codebase but were never caught by tests. If your test suite was already green when the bug was introduced, bisect will not save you. You need a reproduction first. Bisect is a search tool, not a detection tool.
When to reach for bisect instead of blame or log
git blame is great when you already know which file contains the bug. git log --grep is great when you remember something about the commit message. Bisect is for when you know neither. You just know that it worked at point A and fails at point B, and the space between them is too large to reason about.
If your team runs CI on every commit, you can sometimes bisect faster by reading CI history. But CI only catches what it tests. A performance regression, a visual bug, or a subtle logic error that your tests miss will not show up in CI logs. Bisect works for any reproducer you can script, regardless of what your CI validates.
How to make bisect part of your workflow
You do not need a special setup. The commands are built into Git. But there are two habits that make bisect smoother when you actually need it.
First, tag your releases. Bisect needs a known-good commit, and tags are the easiest way to provide one. If your last release was three weeks ago and you know it was clean, git bisect good v1.4.0 is a one-line anchor point.
Second, keep your reproduction minimal. A reproduction that takes thirty seconds to run is tolerable for eight iterations. A reproduction that takes ten minutes is torture. Before you bisect, spend five minutes narrowing the reproduction to the smallest possible command. Your future self will thank you.
When you are done, run git bisect reset immediately. A detached HEAD with uncommitted changes is a great way to lose work. I learned this the hard way so you do not have to.