Git Bisect: Finding Bugs with Binary Search
A practical guide to using git bisect for finding the exact commit that introduced a bug, including manual and automated bisect, scripted testing, and real-world debugging workflows.
Git Bisect: Finding Bugs with Binary Search
A bug appears in production. The code worked two weeks ago. You have 200 commits between then and now. Without git bisect, you would read through commits hoping to spot the problem, or test random commits until you narrow it down. With bisect, you perform a binary search — testing at most 8 commits to find the exact one that introduced the bug out of 200 candidates.
I use bisect several times a month. It finds bugs that would take hours of code reading in five minutes of automated testing. This guide covers manual bisect, automated bisect with scripts, and the patterns that make bisect practical in real projects.
Prerequisites
- Git installed (v2.20+)
- A repository with a bug that was not always present
- Knowledge of at least one commit where the bug did not exist
- A way to test whether the bug is present (manual check or automated test)
How Git Bisect Works
Bisect performs a binary search through commit history:
200 commits between "good" and "bad"
Step 1: Test commit 100 → bad (100 remain)
Step 2: Test commit 50 → good (50 remain)
Step 3: Test commit 75 → bad (25 remain)
Step 4: Test commit 62 → good (13 remain)
Step 5: Test commit 68 → bad (6 remain)
Step 6: Test commit 65 → good (3 remain)
Step 7: Test commit 66 → bad (1 remains)
Step 8: Test commit 66 → FOUND IT!
At each step, bisect eliminates half the remaining commits. For N commits, you need at most log2(N) tests.
| Commits | Maximum Tests |
|---|---|
| 10 | 4 |
| 50 | 6 |
| 100 | 7 |
| 500 | 9 |
| 1000 | 10 |
| 10000 | 14 |
Manual Bisect
Basic Workflow
# Start bisect
git bisect start
# Mark the current commit as bad (has the bug)
git bisect bad
# Mark a known good commit (did not have the bug)
git bisect good v1.2.0
# or
git bisect good abc1234
# Git checks out a commit in the middle
# Bisecting: 100 revisions left to test after this (roughly 7 steps)
# [def5678] feat: add user notification system
Now test the code:
# Run your application, test, or check
npm start
# Does the bug exist? If yes:
git bisect bad
# If the bug does NOT exist:
git bisect good
# Git checks out the next commit to test
# Bisecting: 50 revisions left to test after this (roughly 6 steps)
Repeat until bisect finds the first bad commit:
abc1234 is the first bad commit
commit abc1234
Author: Developer <[email protected]>
Date: Mon Feb 10 14:23:45 2026 -0800
feat: optimize database query caching
src/cache.js | 45 +++++++++++++++++++++++++++++++++++++++++++++
src/db.js | 12 ++++++------
2 files changed, 51 insertions(+), 6 deletions(-)
Finishing Bisect
# Return to your original branch
git bisect reset
# Or reset to a specific ref
git bisect reset main
Viewing Bisect Progress
# See the bisect log
git bisect log
# Visualize the bisect in a graph
git bisect visualize
# or
git bisect visualize --oneline
Replaying a Bisect
Save and replay a bisect session:
# Save the bisect log
git bisect log > bisect-log.txt
# Replay on the same or another machine
git bisect replay bisect-log.txt
Automated Bisect
The real power of bisect is automation. Write a script that tests for the bug, and bisect runs it at every step:
git bisect start
git bisect bad HEAD
git bisect good v1.0.0
# Run the test script at each step
git bisect run npm test
How the Script Works
The script must exit with specific codes:
Exit 0 → Current commit is GOOD (bug not present)
Exit 1-124, 126-127 → Current commit is BAD (bug present)
Exit 125 → Current commit cannot be tested (SKIP)
Exit 128+ → Bisect aborts (fatal error)
Test Script Examples
Test for a failing unit test:
git bisect run npx jest tests/auth.test.js --silent
Test for a specific error:
# scripts/bisect-test.sh
#!/bin/bash
# Install dependencies (in case they changed)
npm install --silent 2>/dev/null
# Run the application and check for the specific error
OUTPUT=$(node src/app.js --dry-run 2>&1)
if echo "$OUTPUT" | grep -q "TypeError: Cannot read property"; then
exit 1 # Bad — bug is present
else
exit 0 # Good — bug not present
fi
chmod +x scripts/bisect-test.sh
git bisect run ./scripts/bisect-test.sh
Test for a performance regression:
#!/bin/bash
# scripts/bisect-perf.sh
npm install --silent 2>/dev/null
# Run benchmark
START=$(date +%s%N)
node scripts/benchmark.js 2>/dev/null
END=$(date +%s%N)
DURATION=$(( (END - START) / 1000000 )) # milliseconds
echo "Duration: ${DURATION}ms"
# If slower than 500ms, it's bad
if [ "$DURATION" -gt 500 ]; then
exit 1 # Bad — regression present
else
exit 0 # Good — performance acceptable
fi
Test for a missing file or wrong output:
#!/bin/bash
# scripts/bisect-output.sh
npm install --silent 2>/dev/null
npm run build --silent 2>/dev/null
# Check if the build produces the expected file
if [ ! -f dist/bundle.js ]; then
exit 1 # Bad — file not generated
fi
# Check if the file contains expected content
if ! grep -q "initializeApp" dist/bundle.js; then
exit 1 # Bad — expected function missing
fi
exit 0 # Good
Handling Untestable Commits
Some commits cannot be tested — broken builds, missing dependencies, or intermediate refactoring states:
# During manual bisect
git bisect skip
# In automated scripts, exit with 125
#!/bin/bash
npm install --silent 2>/dev/null
if [ $? -ne 0 ]; then
exit 125 # Skip — cannot install dependencies
fi
npm test --silent
When bisect encounters skips, it may not find the exact commit but will narrow the range:
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
abc1234 feat: refactor auth module
def5678 fix: update auth tests
ghi9012 feat: add token refresh
We cannot bisect more!
Bisect with Node.js Projects
Handling Dependency Changes
Node.js projects often have dependency changes between commits. Your bisect script needs to handle npm install:
#!/bin/bash
# scripts/bisect-node.sh
# Clean install
rm -rf node_modules 2>/dev/null
npm install --silent 2>/dev/null
if [ $? -ne 0 ]; then
echo "npm install failed — skipping"
exit 125
fi
# Run the specific test
npx jest tests/payment.test.js --silent 2>/dev/null
if [ $? -ne 0 ]; then
exit 1 # Bad
fi
exit 0 # Good
Testing an HTTP Endpoint
#!/bin/bash
# scripts/bisect-api.sh
npm install --silent 2>/dev/null
# Start the server in the background
node app.js &
SERVER_PID=$!
# Wait for server to start
sleep 3
# Test the endpoint
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/health)
# Kill the server
kill $SERVER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
if [ "$STATUS" -ne 200 ]; then
exit 1 # Bad — endpoint broken
fi
exit 0 # Good
Testing for a Specific Log Message
#!/bin/bash
# scripts/bisect-error.sh
npm install --silent 2>/dev/null
# Run the program and capture stderr
OUTPUT=$(node src/process.js --input test-data.json 2>&1)
EXIT_CODE=$?
# Check for the specific error we're hunting
if echo "$OUTPUT" | grep -q "ENOENT.*config/defaults.json"; then
exit 1 # Bad — the file-not-found bug is present
fi
if [ $EXIT_CODE -ne 0 ]; then
exit 125 # Some other error — skip
fi
exit 0 # Good
Advanced Bisect Techniques
Bisecting Merge Commits
By default, bisect follows the first-parent path. To include merge commits:
# Start bisect with --first-parent
git bisect start --first-parent
# This only tests commits on the main branch,
# skipping feature branch commits.
# Useful when the bug was introduced by a merge.
Bisecting Between Tags
git bisect start
git bisect bad v2.3.0
git bisect good v2.2.0
# Only tests commits between these two releases
Bisecting with a Path Filter
If you know the bug is in a specific directory:
git bisect start -- src/auth/
# Only considers commits that changed files in src/auth/
Saving and Sharing Bisect Results
# After finding the bad commit
git bisect log > bisect-session.txt
# Contents:
# git bisect start
# # bad: [abc1234] HEAD
# git bisect bad abc1234
# # good: [def5678] v1.0.0
# git bisect good def5678
# # bad: [ghi9012] feat: optimize cache
# git bisect bad ghi9012
# ...
# # first bad commit: [xyz7890] feat: optimize cache
# Share with team for review
cat bisect-session.txt
Complete Working Example: Finding a Real Bug
// scripts/bisect-find-bug.js
// Automated bisect test for a specific regression
var childProcess = require("child_process");
var http = require("http");
function installDeps() {
try {
childProcess.execSync("npm install --silent", {
stdio: "pipe",
timeout: 60000
});
return true;
} catch (err) {
return false;
}
}
function startServer() {
return new Promise(function(resolve, reject) {
var server = childProcess.spawn("node", ["app.js"], {
env: Object.assign({}, process.env, {
PORT: "3999",
NODE_ENV: "test"
}),
stdio: "pipe"
});
var timeout = setTimeout(function() {
reject(new Error("Server did not start within 5 seconds"));
}, 5000);
server.stdout.on("data", function(data) {
if (data.toString().indexOf("listening") !== -1) {
clearTimeout(timeout);
resolve(server);
}
});
server.stderr.on("data", function(data) {
// Log but don't reject — some warnings are normal
});
server.on("error", function(err) {
clearTimeout(timeout);
reject(err);
});
});
}
function testEndpoint() {
return new Promise(function(resolve, reject) {
var req = http.get("http://localhost:3999/api/users?page=2", function(res) {
var body = "";
res.on("data", function(chunk) { body += chunk; });
res.on("end", function() {
try {
var data = JSON.parse(body);
// The bug: pagination returns page 1 data for all pages
if (data.page !== 2) {
resolve(false); // Bug present
} else if (data.users.length === 0 && data.total > 10) {
resolve(false); // Bug present — empty when shouldn't be
} else {
resolve(true); // Working correctly
}
} catch (err) {
resolve(false);
}
});
});
req.on("error", function() { resolve(false); });
req.setTimeout(3000, function() { req.destroy(); resolve(false); });
});
}
async function main() {
// Step 1: Install dependencies
if (!installDeps()) {
process.exit(125); // Skip — can't install
}
// Step 2: Start server
var server;
try {
server = await startServer();
} catch (err) {
process.exit(125); // Skip — can't start server
}
// Step 3: Test the endpoint
var isGood = await testEndpoint();
// Step 4: Cleanup
server.kill();
// Step 5: Report result
process.exit(isGood ? 0 : 1);
}
main();
Run the automated bisect:
git bisect start
git bisect bad HEAD
git bisect good v1.5.0
git bisect run node scripts/bisect-find-bug.js
Output:
running node scripts/bisect-find-bug.js
Bisecting: 64 revisions left to test after this (roughly 6 steps)
...
running node scripts/bisect-find-bug.js
abc1234def5678 is the first bad commit
commit abc1234def5678
Author: Developer <[email protected]>
Date: Wed Feb 5 09:15:32 2026 -0800
refactor: simplify pagination query
src/routes/users.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
bisect run success
Found it. The pagination query refactor broke the page offset calculation.
Common Issues and Troubleshooting
Bisect says "a]merge base must be tested"
The good and bad commits do not have a clear linear path:
Fix: Test the merge base commit. Git sometimes needs to verify that the common ancestor of your good and bad commits is actually good. Run the test and mark it git bisect good or git bisect bad.
Automated bisect keeps hitting untestable commits
Many commits between good and bad have broken builds:
Fix: Make your bisect script return exit code 125 for any non-bug failure (build errors, missing files, dependency issues). Bisect will skip these commits and continue. If too many commits are skipped, bisect may not pinpoint the exact commit but will narrow the range.
Bisect points to a merge commit
The bug was introduced by a merge, not an individual commit:
Fix: Use git bisect start --first-parent to only test merge commits on the main branch. Once you find the merge, you can bisect within that feature branch to find the specific commit.
"git bisect reset" goes to the wrong branch
Bisect lost track of your original branch:
Fix: Use git bisect reset <branch-name> to explicitly specify where to return. If you forgot your original branch, check git reflog to find where you were before starting bisect.
Bisect script works manually but fails in automation
The script depends on environment state that changes between commits:
Fix: Make the script self-contained. Clean node_modules and reinstall. Set explicit environment variables. Use absolute paths. Kill any background processes from previous runs.
Best Practices
- Write a specific test before starting bisect. Know exactly what you are checking. "The app crashes" is too vague. "GET /api/users?page=2 returns page 1 data" is testable.
- Use
git bisect runwhenever possible. Automated bisect is faster and more reliable than manual testing. Write a script even if it takes 10 minutes — it saves more time than manual stepping. - Handle dependency changes in your script. Run
npm installat each step. Commits from weeks ago may need different dependencies. Exit 125 if installation fails. - Clean up background processes. If your test starts a server, kill it before exiting. Leftover processes from previous bisect steps cause port conflicts and false results.
- Tag known-good releases. Having tagged releases makes it easy to pick a good starting point.
git bisect good v1.2.0is better than guessing commit hashes. - Use
--first-parentfor merge-based workflows. This tests only the merge points on the main branch, which is usually what you want. You can always bisect within a specific branch later. - Save bisect logs. After finding the bad commit, save the log with
git bisect log > bisect.txt. This documents the debugging process for post-mortems and future reference.