A regression is a behavior that used to work and now does not. When one appears, you usually know two facts: the code worked at some earlier commit, and it is broken at the current one. A commit is a snapshot of the codebase at a point in time. Somewhere between those two snapshots, a change introduced the bug, and you need to find which one. git bisect uses binary search. Git checks out the middle commit of the range. You test it, and Git halves the candidate commits based on your answer. When the regression sits somewhere in a range of 1,000 commits, bisect finds it in about ten tests.
The approach depends on one thing: you can reliably tell, for any given commit, whether the bug is present. You supply a known good commit, where the bug is absent, and a known bad commit, where it is present. Your current HEAD is usually the bad commit, and a release tag is the good one. Git then walks the range and checks out a midpoint for you to test. It narrows the search with each verdict until one commit remains: the commit where the regression entered the codebase.
Start with a Good Commit and a Bad Commit
Decide the two endpoints before you start. In most cases your current HEAD is the bad commit and the last release tag is the good one. A release tag is a named commit that marks a shipped version. List recent tags to find a good starting point, then count the commits between the tag and HEAD to see how many commits the search will cover:
git tag --sort=-creatordate | head
git rev-list --count v1.4.0..HEAD
The count tells you how many commits the search will cover before you start.
Verify the good commit before you rely on it. Check it out, build, and confirm the bug is absent. A good commit that actually contains the bug makes every later verdict wrong, because each step inherits the previous ones.
Start the session with one command, bad first and then good: git bisect start HEAD v1.4.0. You can also run git bisect start on its own, then git bisect bad and git bisect good v1.4.0 separately.
Make sure the working tree is clean before you start. Bisect checks out a different commit at every step; uncommitted changes can conflict with the checkout or make a clean commit behave like a broken one. Commit or stash your work first.
Test Each Midpoint by Hand
When the session starts, Git checks out a commit in the middle of the range and prints how many commits remain. Test that exact state: run the code, try the broken behavior, and decide whether the bug is present.
Run git bisect bad if the bug is present, and git bisect good if it is absent. Git checks out the next midpoint, and each verdict halves the remaining range. You will run eight tests for 214 commits, about ten for 1,000, and about fourteen for 10,000.
When a single candidate remains, Git prints the hash and subject line of the first bad commit. That commit is the regression.
Two situations can interrupt the loop. If bisect lands on a merge commit, the change it represents is the whole merged branch, not the actual cause; bisect that branch’s own range to find the actual commit. If a commit cannot be tested at all, do not guess. Mark it with git bisect skip so Git picks another candidate.
When the session is over, run git bisect reset. Reset restores the branch you started from and clears the bisect state. If you skip it, your working tree stays on whatever commit the search last checked out.
Automate the Search with git bisect run
Testing every midpoint by hand is fine when the check is quick. When it takes minutes, or when you want to run the search unattended, git bisect run runs the loop for you. You give it a command, and Git runs that command at each candidate commit and reads the exit code as the verdict.
The exit code mapping:
- Exit code 0 marks the commit as good.
- Any exit code from 1 to 127, except 125, marks the commit as bad.
- 125 tells Git to skip the commit because it cannot be tested.
For anything beyond a single command, write a script and pass it to bisect. Keep the script outside the repository, for example in ~/bin, because Git checks out a different commit at every step; a script stored inside the repo can change or disappear as the search moves through history.
A minimal script for a Node project looks like this:
#!/usr/bin/env bash
cd /path/to/repo
npm ci || exit 125
npm test
npm ci removes and reinstalls dependencies, which matters because the dependency manifest changes between commits. The final npm test exits 0 when tests pass and non-zero when they fail, so its exit code becomes the verdict. Run the automated search with git bisect run ~/bin/check-checkout.sh.
When the check needs a server, the script has to manage that process. Add a cleanup trap so a leftover server does not affect the next commit’s run. Wait for the server to answer with a retry loop instead of a fixed sleep. Use a non-standard port so the check does not conflict with a development server already running:
#!/usr/bin/env bash
cd /path/to/repo
npm ci || exit 125
npm run start -- --port 8099 &
server_pid=$!
trap 'kill $server_pid' EXIT
for i in $(seq 1 30); do
curl -s http://localhost:8099/health > /dev/null && break
sleep 1
done
npm test
The start command will differ for your project; the important part is that the script starts the server, waits until it answers, runs the check, and lets the trap clean up. If the check is a single command with text output, wrap it in a small script that inspects the output; if the program is interactive, run it under a timeout so a hanging prompt does not stall the whole bisect.
Keep Flaky Checks from Producing a Wrong Answer
Bisect trusts every verdict it receives. A test that fails once out of ten runs can split the range the wrong way, and the search ends at a commit that has nothing to do with the regression. The reliability of the check matters more than its speed.
Never mark an untestable commit as good just to keep the session moving. A false good sends the search into the wrong half of history, and every later step builds on the mistake. When you cannot judge a commit, because the build fails, the feature did not exist yet, or the environment is broken, tell Git to skip it with git bisect skip. In an automated run, exit 125 from your script does the same thing. You can also skip a whole range, for example commits that never compiled: git bisect skip v1.4.2..v1.4.5.
If you skip too many commits, Git may run out of testable candidates and end the search with a range of suspects instead of a single one.
For a check that is only fails sometimes, a practical pattern is to have the script run it several times and require the runs to agree. When every run passes, report good; when every run fails, report bad; when the runs disagree, exit 125 so Git skips the commit instead of recording a verdict based on noise. Run the check in a consistent environment, ideally a container or VM, because dependencies and build tools change across commits and can change the outcome by themselves.
Bisect Performance Regressions with a Threshold
git bisect run needs a yes or no answer, but a performance regression is a number. Bisect it by measuring at each commit and comparing the result against a threshold.
Choose the threshold from your own measurements, not a round number. Measure the known-good commit and the known-bad commit first; the threshold belongs between those two values, with enough margin that normal variance does not cross it. Then write a script that measures the metric, compares it to the threshold, and exits 0 when the commit is fast enough and 1 when it is not:
#!/usr/bin/env bash
cd /path/to/repo
npm ci || exit 125
npm run start -- --port 8099 &
server_pid=$!
trap 'kill $server_pid' EXIT
for i in $(seq 1 30); do
curl -s http://localhost:8099/health > /dev/null && break
sleep 1
done
# average response time over 10 requests
mean=$(for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{time_total}\n" http://localhost:8099/health
done | awk '{sum += $1; n++} END {print sum / n}')
echo "average response time: ${mean}s"
# THRESHOLD is a value between your known-good and known-bad measurements
awk -v mean="$mean" -v limit="$THRESHOLD" 'BEGIN { exit (mean < limit) ? 0 : 1 }'
Run THRESHOLD=<your-value> git bisect run ~/bin/check-perf.sh. Average the measurement over several requests, five to ten, and keep the environment consistent across runs, because the same commit can measure differently on a busy machine. A flaky measurement misleads bisect exactly like a flaky test does: the search converges on the commit that happened to measure badly, not the commit that caused the regression.
Narrow the Search and Recover from Mistakes
When the bug is clearly confined to one area of the code, restrict the search to commits that touched that path: git bisect start HEAD v1.4.0 -- src/parser. Git only considers commits that modified src/parser, so the session is shorter. Use this when you are confident about the area; a regression that lives elsewhere will escape the narrowed search.
For a long-running main branch with many merges, --first-parent keeps the search on the main-line commits instead of descending into every merged branch: git bisect start --first-parent HEAD v1.4.0.
--no-checkout runs the search without touching the working tree: git bisect start --no-checkout HEAD v1.4.0. This helps when a checkout is expensive and the test manages its own copy of the code. The candidate commit is exposed as BISECT_HEAD, and your script has to check it out itself.
Mistakes happen, and bisect provides an undo path. git bisect log prints the full session history: every commit you marked and the verdict you gave. Save that output to a file, remove or correct the wrong line, and replay the corrected history:
git bisect log > session.log
# edit session.log to fix the mistaken verdict
git bisect reset
git bisect replay session.log
git bisect visualize opens the remaining candidates in a history viewer, and git log --bisect lists them on the command line.
One direction issue remains. When you are searching for the commit that fixed a bug rather than introduced one, the words good and bad point the wrong way. Git 2.7 added neutral terms, old and new, so you can mark commits with git bisect old and git bisect new. You can also rename the states to fit the search, for example git bisect start --term-old=broken --term-new=fixed.
Mislabeling a commit, especially the initial good commit, is the most common cause of a wrong result. When the search ends somewhere unexpected, replay the session from the log and double-check each verdict before trusting the answer.
Read the Diff and Decide the Fix
When bisect finishes, Git prints the hash of the first bad commit. Look at what that commit changed with git show <commit>. The diff is your root cause candidate. If the commit is large, it identifies the change but not the specific line; read through the diff to find the part that broke the behavior.
On a shared branch, reverting the offending commit with git revert <commit> is usually the fastest safe move. It restores the behavior immediately while you investigate a proper fix, and it does not rewrite history that other people have built on. Run git bisect reset when the session is over so your working tree returns to the branch you started from.