Git Bisect: Finding Bugs with Binary Search
A practical guide to using git bisect for finding bugs through binary search, covering manual and automated bisect, test scripts, performance regressions, and real-world debugging scenarios.
Git Bisect: Finding Bugs with Binary Search
Overview
git bisect is one of the most underused tools in a developer's arsenal. It uses binary search across your commit history to pinpoint the exact commit that introduced a bug, turning a potentially hours-long manual search through hundreds of commits into a handful of tests. If you have ever stared at a broken test suite after a week of merges and thought "something changed, but I have no idea what," bisect is the answer.
Prerequisites
- Git 2.x installed
- A working understanding of git log, git checkout, and basic branching
- Node.js 18+ for the JavaScript examples
- Familiarity with writing shell scripts (basic bash)
- A project with meaningful commit history (at least 10+ commits)
How Binary Search Applies to Commit History
Binary search is one of the first algorithms you learn in computer science. Given a sorted list, you check the midpoint, determine which half your target is in, and repeat. The time complexity is O(log n) — searching 1,000 items takes at most 10 checks. Git bisect applies this exact principle to your commit history.
Think of your commit history as a sorted timeline. At some point in the past, the code worked (a "good" commit). At some point more recently, it stopped working (a "bad" commit). Between those two commits lies the one that broke things. Instead of checking every commit sequentially — which could mean testing 500 commits — bisect splits the range in half, you test the midpoint, and then it splits the remaining range again.
For a range of 1,024 commits, you will find the offending commit in at most 10 steps. For 100 commits, about 7 steps. That is the power of logarithmic search.
Commit history (simplified):
a1b2c3d <-- known bad (HEAD)
...
(hundreds of commits)
...
f4e5d6c <-- known good (worked 3 weeks ago)
Without bisect: test all N commits = O(n)
With bisect: test log2(N) commits = O(log n)
N = 512 commits
Without bisect: up to 512 tests
With bisect: 9 tests maximum
Basic Git Bisect Workflow
The manual workflow has four steps: start, mark bad, mark good, and test until git finds the commit.
Starting a Bisect Session
# Start the bisect session
git bisect start
# Mark the current commit as bad (it has the bug)
git bisect bad
# Mark a known good commit (before the bug existed)
git bisect good f4e5d6c
You can also combine the start with the bad and good markers:
# Start with bad and good in one command
git bisect start HEAD f4e5d6c
After you mark good and bad, git immediately checks out the midpoint commit. Your working directory is now at a commit halfway between the good and bad markers.
Testing and Marking
At each step, you test whether the current commit has the bug. Then you tell git:
# If this commit has the bug
git bisect bad
# If this commit does NOT have the bug
git bisect good
Git then checks out the next midpoint in the remaining range. You repeat until git identifies the first bad commit:
Bisecting: 128 revisions left to test after this (roughly 7 steps)
[a3c4f5e] Refactored user authentication module
# You test... it works here.
$ git bisect good
Bisecting: 64 revisions left to test after this (roughly 6 steps)
[b7d8e9f] Updated dependency versions
# You test... it is broken here.
$ git bisect bad
Bisecting: 32 revisions left to test after this (roughly 5 steps)
[c1a2b3c] Added rate limiting middleware
# ... repeat until:
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 is the first bad commit
commit a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
Author: [email protected]
Date: Mon Jan 15 14:23:00 2026 -0800
Changed JSON response format for /api/users endpoint
Ending the Session
When bisect finds the commit, or when you want to abort:
# End bisect and return to your original branch
git bisect reset
# Or reset to a specific ref instead of the original HEAD
git bisect reset main
Always reset when you are done. If you forget, you will be in a detached HEAD state and wonder why your branches look wrong.
Automated Bisect with git bisect run
Manual bisecting works, but it still requires you to sit there, test each commit, and type good or bad. The real power of bisect comes from automation. git bisect run takes a script or command and runs it at each step automatically.
The rule is simple: if the script exits with code 0, the commit is marked good. If it exits with any code between 1 and 124 (inclusive, except 125), the commit is marked bad. Exit code 125 is special — it tells bisect to skip that commit.
# Automated bisect with a test command
git bisect start HEAD f4e5d6c
git bisect run npm test
That is it. Git will check out each midpoint, run npm test, interpret the exit code, and keep going until it finds the first bad commit. You can walk away and get coffee.
Bisecting with Custom Test Scripts
Often npm test is too broad. You want to test one specific thing. Write a small script:
#!/bin/bash
# bisect-test.sh - Test if the /api/users endpoint returns correct format
# Install dependencies (they may differ per commit)
npm install --silent 2>/dev/null
# Start the server in the background
node app.js &
SERVER_PID=$!
# Wait for server to be ready
sleep 2
# Test the endpoint
RESPONSE=$(curl -s http://localhost:3000/api/users)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/users)
# Kill the server
kill $SERVER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
# Check the response
if [ "$STATUS" != "200" ]; then
echo "FAIL: Status code is $STATUS, expected 200"
exit 1
fi
# Check that response contains expected field
echo "$RESPONSE" | grep -q '"users"'
if [ $? -ne 0 ]; then
echo "FAIL: Response missing 'users' field"
exit 1
fi
echo "PASS: Endpoint returns correct format"
exit 0
Make it executable and run:
chmod +x bisect-test.sh
git bisect start HEAD f4e5d6c
git bisect run ./bisect-test.sh
Bisecting with npm test
If your project has a well-structured test suite, you can target specific test files:
# Run only the tests for the users API
git bisect run npx mocha test/api/users.test.js
# Or with Jest
git bisect run npx jest --testPathPattern="users.test" --forceExit
# Or a specific npm script
git bisect run npm run test:api
One thing I have learned the hard way: make sure the test file itself exists at every commit in your bisect range. If you wrote the test after the bug was introduced, bisect will not find the test file on older commits and the script will fail with an error, not a clean exit code. In that case, put your test script outside the repository or use a path that is stable across all commits.
# Copy the test script somewhere outside the repo first
cp test/api/users.test.js /tmp/bisect-test.js
# Run it from outside
git bisect run node /tmp/bisect-test.js
Handling Untestable Commits with git bisect skip
Sometimes a commit cannot be tested. Maybe the build is broken at that commit for an unrelated reason, or a dependency is missing. You do not want to mark it as good or bad because neither is accurate.
# During manual bisect, skip the current commit
git bisect skip
# Skip a specific commit or range
git bisect skip a1b2c3d
git bisect skip a1b2c3d..f4e5d6c
In an automated script, use exit code 125 to trigger a skip:
#!/bin/bash
# bisect-test-with-skip.sh
# Try to install dependencies
npm install --silent 2>/dev/null
if [ $? -ne 0 ]; then
echo "SKIP: npm install failed at this commit"
exit 125
fi
# Try to build
npm run build 2>/dev/null
if [ $? -ne 0 ]; then
echo "SKIP: Build failed at this commit (unrelated)"
exit 125
fi
# Run the actual test
npm test
When bisect encounters too many skips in a row, it may not be able to determine the exact first bad commit. It will tell you:
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
a1b2c3d Changed config format
b4c5d6e Removed legacy module
c7d8e9f Updated CI pipeline
We cannot bisect more!
In that case, you need to manually test those remaining commits.
Bisecting Across Merge Commits
Real-world repositories have merge commits, feature branches, and non-linear histories. Bisect handles this fine — it works on the linearized history (the list of commits reachable from bad but not from good).
However, merge commits can cause confusion. When bisect checks out a merge commit, the state of the code reflects the merge result. If the bug was introduced in a side branch that was merged, bisect will still find the merge commit or drill into the branch if those commits are in the range.
One useful option is --first-parent, which limits bisect to only the first-parent history (the main branch commits and merge commits, but not the individual commits within merged branches):
# Only bisect along the main branch timeline
git bisect start --first-parent HEAD f4e5d6c
git bisect run npm test
This is useful when you have a long-running main branch with many merged feature branches. Instead of drilling into each feature branch, you find which merge introduced the problem. Then you can bisect within that feature branch separately if needed.
Bisecting Performance Regressions
Bisect is not just for functional bugs. I use it regularly for performance regressions. The key is writing a test script that measures performance and exits with the appropriate code.
#!/bin/bash
# bisect-perf.sh - Find the commit that made /api/search slow
npm install --silent 2>/dev/null || exit 125
node app.js &
SERVER_PID=$!
sleep 2
# Measure response time (in seconds)
RESPONSE_TIME=$(curl -s -o /dev/null -w "%{time_total}" http://localhost:3000/api/search?q=test)
kill $SERVER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
# Convert to milliseconds for easier comparison
RESPONSE_MS=$(echo "$RESPONSE_TIME * 1000" | bc | cut -d. -f1)
echo "Response time: ${RESPONSE_MS}ms"
# Threshold: anything over 500ms is "bad"
if [ "$RESPONSE_MS" -gt 500 ]; then
echo "FAIL: Response took ${RESPONSE_MS}ms (threshold: 500ms)"
exit 1
fi
echo "PASS: Response time acceptable"
exit 0
git bisect start HEAD v2.1.0
git bisect run ./bisect-perf.sh
A few things to watch out for with performance bisecting:
- Run multiple iterations. Network jitter, garbage collection, cold caches — any of these can cause a single request to be slow. Average over 5-10 requests in your script.
- Use a consistent environment. Run on the same machine, close other applications, use a consistent dataset.
- Set your threshold with margin. If the expected response is 100ms and the regression made it 800ms, set the threshold at 300-400ms, not 150ms. You want a clean signal.
Here is a more robust version with multiple iterations:
// bisect-perf-check.js
var http = require("http");
var TARGET_URL = "http://localhost:3000/api/search?q=test";
var THRESHOLD_MS = 500;
var ITERATIONS = 5;
function measureRequest(url, callback) {
var start = Date.now();
http.get(url, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
var elapsed = Date.now() - start;
callback(null, elapsed);
});
}).on("error", function(err) {
callback(err);
});
}
function runIterations(remaining, times, done) {
if (remaining === 0) {
return done(null, times);
}
measureRequest(TARGET_URL, function(err, elapsed) {
if (err) {
return done(err);
}
times.push(elapsed);
runIterations(remaining - 1, times, done);
});
}
runIterations(ITERATIONS, [], function(err, times) {
if (err) {
console.error("Error measuring performance:", err.message);
process.exit(125); // skip - cannot test
}
var sum = 0;
for (var i = 0; i < times.length; i++) {
sum += times[i];
}
var avg = Math.round(sum / times.length);
console.log("Times:", times.join(", "), "ms");
console.log("Average:", avg, "ms");
if (avg > THRESHOLD_MS) {
console.log("FAIL: Average " + avg + "ms exceeds threshold " + THRESHOLD_MS + "ms");
process.exit(1);
}
console.log("PASS: Average " + avg + "ms within threshold");
process.exit(0);
});
Creating Effective Bisect Scripts for Node.js Projects
After running hundreds of bisect sessions on Node.js projects, I have settled on a pattern for writing reliable bisect scripts. The biggest challenge is that dependencies, build tools, and configuration can change across the commit range. Your bisect script needs to handle all of that gracefully.
The Template
#!/bin/bash
# bisect-check.sh - Template for Node.js bisect scripts
set -e
# === CLEANUP ===
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill $SERVER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
fi
}
trap cleanup EXIT
# === SETUP ===
# Clean node_modules and reinstall (dependencies change between commits)
rm -rf node_modules
npm install --silent 2>/dev/null
if [ $? -ne 0 ]; then
echo "SKIP: npm install failed"
exit 125
fi
# === BUILD (if applicable) ===
if [ -f "package.json" ] && grep -q '"build"' package.json; then
npm run build 2>/dev/null
if [ $? -ne 0 ]; then
echo "SKIP: build failed"
exit 125
fi
fi
# === START SERVER ===
PORT=3999 node app.js &
SERVER_PID=$!
# Wait for server to be ready (retry loop)
for i in $(seq 1 10); do
curl -s http://localhost:3999/health > /dev/null 2>&1 && break
sleep 1
done
# === TEST ===
# Replace this with your actual test logic
RESULT=$(curl -s http://localhost:3999/api/endpoint)
# === EVALUATE ===
echo "$RESULT" | grep -q '"expected_field"'
if [ $? -ne 0 ]; then
echo "FAIL: Missing expected field in response"
exit 1
fi
echo "PASS"
exit 0
Key principles:
- Always clean and reinstall
node_modules. Dependencies change between commits. A stalenode_modulesdirectory will give you false results. - Use a non-standard port. You do not want conflicts with a running development server.
- Use a cleanup trap. Always kill background processes on exit, even if the script fails.
- Use exit code 125 for infrastructure failures. If the project cannot even start at a given commit, skip it.
- Wait for the server to be ready. Do not just
sleep 2and hope. Use a retry loop.
Keeping the Test Script Outside the Repo
This is important. If your test script is inside the repository, it will change (or disappear) as bisect checks out different commits. Put it somewhere stable:
# Copy the script outside the repo
cp bisect-check.sh /tmp/bisect-check.sh
chmod +x /tmp/bisect-check.sh
# Run bisect with the external script
git bisect start HEAD abc1234
git bisect run /tmp/bisect-check.sh
Git Bisect Log and Replay
Bisect keeps a log of every step. This is incredibly useful for reproducibility and documentation.
Viewing the Log
# During or after a bisect session
git bisect log
Output looks like this:
git bisect start
# status: waiting for both good and bad commits
# bad: [a1b2c3d] HEAD - Current state with bug
git bisect bad a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# good: [f4e5d6c] Last known working release
git bisect good f4e5d6c7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3
# bad: [b7d8e9f] Updated dependency versions
git bisect bad b7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
# good: [c1a2b3c] Added rate limiting middleware
git bisect good c1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0
# first bad commit: [d4e5f6a] Changed JSON response format
Saving and Replaying
You can save the log to a file and replay the exact same bisect session later:
# Save the log
git bisect log > bisect-session.log
# Later, replay it
git bisect replay bisect-session.log
This is useful when you want to share a bisect result with a teammate, or when you need to redo the bisect after making changes to your test script.
Visualizing Bisect with Git Log
You can use git log with the --bisect flag to visualize the remaining commits in a bisect session:
# Show the commits still being considered
git log --bisect --oneline
# More detailed view with graph
git log --bisect --oneline --graph --decorate
You can also use git bisect visualize (or git bisect view) to open the remaining commits in gitk or your configured viewer:
# Open the remaining range in gitk
git bisect visualize
# Or use log format instead
git bisect visualize --oneline
Real-World Bisect Scenarios
Scenario 1: A Dependency Upgrade Broke Something
You upgraded 15 npm packages in a single commit batch over multiple days. Now your API returns malformed dates. You know it worked two weeks ago.
# Find the last known good tag or commit
git log --oneline --since="2 weeks ago" | tail -1
# Output: f4e5d6c Deployed v2.3.0
git bisect start HEAD f4e5d6c
# Write a quick test
cat > /tmp/date-check.sh << 'SCRIPT'
#!/bin/bash
npm install --silent 2>/dev/null || exit 125
node -e "
var app = require('./app');
// Quick check: does the date formatter produce ISO strings?
var utils = require('./utils/dates');
var result = utils.formatDate(new Date('2026-01-15'));
if (result && result.match(/^\d{4}-\d{2}-\d{2}/)) {
process.exit(0);
} else {
console.error('Bad date format:', result);
process.exit(1);
}
"
SCRIPT
chmod +x /tmp/date-check.sh
git bisect run /tmp/date-check.sh
Result: bisect identifies the commit where moment.js was upgraded from 2.29 to 2.30 and a deprecated format string started returning a different output.
Scenario 2: CSS Regression
The site's navigation bar started overlapping the main content area. This is harder to bisect because it is a visual issue, but you can still script it.
#!/bin/bash
# bisect-css.sh - Check if a specific CSS rule exists
npm install --silent 2>/dev/null || exit 125
# Check if the CSS file has the correct z-index
if [ -f "static/css/styles.css" ]; then
grep -q "\.navbar.*z-index:\s*1030" static/css/styles.css
if [ $? -eq 0 ]; then
echo "PASS: navbar z-index is correct"
exit 0
else
echo "FAIL: navbar z-index missing or wrong"
exit 1
fi
else
echo "SKIP: styles.css not found"
exit 125
fi
For more complex CSS regressions, you can use headless Chrome with Puppeteer to take screenshots and compare them, though that is more setup:
// bisect-visual.js - place outside the repo at /tmp/bisect-visual.js
var puppeteer = require("puppeteer");
var fs = require("fs");
(function() {
var EXPECTED_NAV_HEIGHT = 60;
var TOLERANCE = 10;
puppeteer.launch({ headless: "new" }).then(function(browser) {
return browser.newPage().then(function(page) {
return page.goto("http://localhost:3000").then(function() {
return page.evaluate(function() {
var nav = document.querySelector(".navbar");
var main = document.querySelector(".main-content");
if (!nav || !main) return null;
return {
navBottom: nav.getBoundingClientRect().bottom,
mainTop: main.getBoundingClientRect().top
};
});
}).then(function(metrics) {
browser.close();
if (!metrics) {
console.log("SKIP: Could not find elements");
process.exit(125);
}
var gap = metrics.mainTop - metrics.navBottom;
console.log("Nav bottom:", metrics.navBottom, "Main top:", metrics.mainTop, "Gap:", gap);
if (gap < 0) {
console.log("FAIL: Navigation overlaps content by", Math.abs(gap), "px");
process.exit(1);
}
console.log("PASS: No overlap");
process.exit(0);
});
});
}).catch(function(err) {
console.error("Error:", err.message);
process.exit(125);
});
})();
Scenario 3: API Response Changed
Your API consumers report that a field they depend on is missing from the response. You are not sure when it disappeared.
#!/bin/bash
# bisect-api-field.sh
npm install --silent 2>/dev/null || exit 125
PORT=4567 node app.js &
SERVER_PID=$!
sleep 3
RESPONSE=$(curl -s http://localhost:4567/api/v1/products/1)
kill $SERVER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
# Check for the required field
echo "$RESPONSE" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if 'inventory_count' in data:
print('PASS: inventory_count field present')
sys.exit(0)
else:
print('FAIL: inventory_count field missing')
print('Fields present:', list(data.keys()))
sys.exit(1)
except Exception as e:
print('SKIP: Could not parse response -', str(e))
sys.exit(125)
"
Complete Working Example: Automated Express.js API Bisect
Here is a complete, production-ready bisect workflow that finds which commit broke an Express.js API endpoint. This script checks response format, status codes, and performance thresholds all in one pass.
The Bisect Script
#!/bin/bash
# bisect-api-full.sh
# Complete automated bisect script for Express.js API validation
# Place this file OUTSIDE your repository (e.g., /tmp/bisect-api-full.sh)
set -o pipefail
PORT=4321
BASE_URL="http://localhost:$PORT"
ENDPOINT="/api/users"
PERF_THRESHOLD_MS=1000
EXPECTED_STATUS=200
REQUIRED_FIELDS=("users" "total" "page")
MAX_STARTUP_WAIT=15
# === Cleanup handler ===
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 $SERVER_PID 2>/dev/null; then
kill $SERVER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
fi
}
trap cleanup EXIT
# === Step 1: Install dependencies ===
echo "--- Installing dependencies ---"
rm -rf node_modules
npm install --silent 2>/dev/null
if [ $? -ne 0 ]; then
echo "SKIP: npm install failed at this commit"
exit 125
fi
# === Step 2: Check that app.js exists ===
if [ ! -f "app.js" ]; then
echo "SKIP: app.js not found at this commit"
exit 125
fi
# === Step 3: Start the server ===
echo "--- Starting server on port $PORT ---"
PORT=$PORT node app.js &
SERVER_PID=$!
# Wait for server to be ready
READY=0
for i in $(seq 1 $MAX_STARTUP_WAIT); do
if curl -s -o /dev/null "$BASE_URL" 2>/dev/null; then
READY=1
break
fi
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "SKIP: Server did not start within ${MAX_STARTUP_WAIT}s"
exit 125
fi
echo "Server ready after ~${i}s"
# === Step 4: Check status code ===
echo "--- Testing status code ---"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$ENDPOINT")
if [ "$STATUS" != "$EXPECTED_STATUS" ]; then
echo "FAIL: Expected status $EXPECTED_STATUS, got $STATUS"
exit 1
fi
echo "PASS: Status code is $STATUS"
# === Step 5: Check response format ===
echo "--- Testing response format ---"
RESPONSE=$(curl -s "$BASE_URL$ENDPOINT")
# Verify it is valid JSON
echo "$RESPONSE" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null
if [ $? -ne 0 ]; then
echo "FAIL: Response is not valid JSON"
echo "Response body: $(echo "$RESPONSE" | head -c 200)"
exit 1
fi
echo "PASS: Response is valid JSON"
# Check for required fields
for FIELD in "${REQUIRED_FIELDS[@]}"; do
echo "$RESPONSE" | python3 -c "
import sys, json
data = json.load(sys.stdin)
if '$FIELD' not in data:
print('FAIL: Missing required field: $FIELD')
sys.exit(1)
print('PASS: Found field: $FIELD')
" 2>/dev/null
if [ $? -ne 0 ]; then
exit 1
fi
done
# === Step 6: Check performance ===
echo "--- Testing performance ---"
TOTAL_MS=0
ITERATIONS=3
for i in $(seq 1 $ITERATIONS); do
TIME_S=$(curl -s -o /dev/null -w "%{time_total}" "$BASE_URL$ENDPOINT")
TIME_MS=$(echo "$TIME_S * 1000" | bc | cut -d. -f1)
TOTAL_MS=$((TOTAL_MS + TIME_MS))
echo " Request $i: ${TIME_MS}ms"
done
AVG_MS=$((TOTAL_MS / ITERATIONS))
echo "Average response time: ${AVG_MS}ms (threshold: ${PERF_THRESHOLD_MS}ms)"
if [ "$AVG_MS" -gt "$PERF_THRESHOLD_MS" ]; then
echo "FAIL: Average response time ${AVG_MS}ms exceeds threshold ${PERF_THRESHOLD_MS}ms"
exit 1
fi
echo "=== ALL CHECKS PASSED ==="
exit 0
Running the Full Bisect
# 1. Copy the script outside the repo
cp bisect-api-full.sh /tmp/bisect-api-full.sh
chmod +x /tmp/bisect-api-full.sh
# 2. Identify your good and bad commits
git log --oneline -20
# Find the last known good commit (e.g., a release tag)
# 3. Start bisect
git bisect start HEAD v2.3.0
# 4. Run automated bisect
git bisect run /tmp/bisect-api-full.sh
# 5. Review the result
git show # Shows the diff of the first bad commit
git bisect log > bisect-result.log # Save for documentation
# 6. Clean up
git bisect reset
Example Output
running /tmp/bisect-api-full.sh
--- Installing dependencies ---
--- Starting server on port 4321 ---
Server ready after ~2s
--- Testing status code ---
PASS: Status code is 200
--- Testing response format ---
PASS: Response is valid JSON
PASS: Found field: users
PASS: Found field: total
PASS: Found field: page
--- Testing performance ---
Request 1: 45ms
Request 2: 38ms
Request 3: 41ms
Average response time: 41ms (threshold: 1000ms)
=== ALL CHECKS PASSED ===
Bisecting: 8 revisions left to test after this (roughly 3 steps)
[e4f5a6b] Merged feature/user-search branch
running /tmp/bisect-api-full.sh
--- Installing dependencies ---
--- Starting server on port 4321 ---
Server ready after ~2s
--- Testing status code ---
PASS: Status code is 200
--- Testing response format ---
PASS: Response is valid JSON
FAIL: Missing required field: page
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[f7a8b9c] Refactored pagination logic
...
e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 is the first bad commit
commit e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3
Author: [email protected]
Date: Thu Jan 20 09:14:22 2026 -0800
Refactored pagination - moved to cursor-based pagination
Replaced offset/page pagination with cursor-based approach.
Response now uses 'cursor' and 'has_more' instead of 'page' and 'total'.
Now you know exactly which commit changed the response format, who wrote it, and what the intent was. You can have an informed conversation about backward compatibility instead of guessing.
Common Issues and Troubleshooting
1. "You need to start by git bisect start"
fatal: You need to start by "git bisect start"
You tried to mark a commit as good or bad without starting a session first. Or your previous session was not properly reset. Run:
git bisect reset
git bisect start
git bisect bad HEAD
git bisect good <known-good-commit>
2. "Some good revs are not ancestors of the bad rev"
Some good revs are not ancestors of the bad rev.
git bisect cannot work properly in this case.
Maybe you mistake good and bad revs?
This happens when the commit you marked as "good" is not actually an ancestor of the commit you marked as "bad." Common causes: you mixed up the good/bad markers, or you are trying to bisect across unrelated branches. Double-check your commit history:
# Verify that good is an ancestor of bad
git merge-base --is-ancestor <good-commit> <bad-commit>
echo $? # 0 means yes, 1 means no
3. Bisect Script Fails Because node_modules Is Stale
Error: Cannot find module 'express'
at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
Your dependencies changed between commits but node_modules was not updated. Always delete and reinstall in your bisect script:
rm -rf node_modules package-lock.json
npm install --silent 2>/dev/null || exit 125
Some prefer keeping package-lock.json to get deterministic installs. Whether to delete it depends on whether the lockfile is committed and consistent across your bisect range.
4. Port Already in Use
Error: listen EADDRINUSE: address already in use :::3000
A previous bisect iteration did not clean up the server process. Your test script needs a proper cleanup trap. Also, use a non-standard port to avoid conflicts:
# Kill any leftover processes on the port
lsof -ti :4321 | xargs kill -9 2>/dev/null
# Or on Windows:
# netstat -ano | findstr :4321
# taskkill /PID <pid> /F
Always use a trap in your bisect scripts:
trap 'kill $SERVER_PID 2>/dev/null; wait $SERVER_PID 2>/dev/null' EXIT
5. "bisect run failed: exit code 128"
bisect run failed:
exit code 128 from './bisect-test.sh' is < 0 or >= 128
Exit codes 128 and above are reserved by bisect and treated as errors that abort the entire bisect session. This usually means your script encountered a fatal error (like a segfault or a signal). Common cause: git commands inside your bisect script failing because you are already in a bisect/detached HEAD state. Avoid running git checkout or git operations inside your bisect test script.
6. Detached HEAD After Forgetting to Reset
$ git branch
* (HEAD detached at a1b2c3d)
main
feature/new-api
You forgot to run git bisect reset. Just run it now:
git bisect reset
# This returns you to the branch you were on when you started bisect
Best Practices
Always store bisect scripts outside the repository. Files inside the repo change as bisect checks out different commits. Copy your test script to
/tmp/or another stable location before starting.Use exit code 125 liberally. If anything about the test infrastructure fails (npm install, build step, server startup), exit 125 to skip that commit. Never mark an untestable commit as good or bad — it corrupts your results.
Clean node_modules at every step. Yes, it is slower. Yes, it is necessary. Dependencies change between commits, and stale modules produce false positives and false negatives. If speed is critical, consider only cleaning when
package.jsonhas changed.Kill background processes with a trap. Every bisect script that starts a server must have a cleanup trap. Orphaned processes cause port conflicts that break subsequent iterations and can snowball into a completely failed bisect session.
Save your bisect log. Run
git bisect log > bisect-session.logwhen you find the answer. This serves as documentation for why a commit was identified as the culprit, and it lets teammates replay the bisect session independently.Use
--first-parentfor large repos with many merges. This narrows the search to merge commits on the main branch, which is usually what you want when triaging production bugs. You can always do a second, more targeted bisect within the offending feature branch.Write atomic commits. This is not a bisect tip per se, but it makes bisect dramatically more useful. If every commit is a single logical change, bisect points you to a 10-line diff instead of a 500-line diff. Teams that squash everything into giant commits get less value from bisect.
Automate whenever possible. Manual bisecting is tedious and error-prone after the fourth or fifth iteration. Spend five minutes writing a script and let the machine do the work. Even a crude script beats manual testing.
Consider bisecting in CI. Some teams run bisect in their CI pipeline when a regression is detected. The pipeline identifies the last green build, bisects between that and the current red build, and posts the result to the pull request. This is advanced but extremely powerful for large teams.
Test your bisect script before starting. Run it on the known-good commit (should exit 0) and the known-bad commit (should exit 1) before launching the full bisect. There is nothing worse than waiting through 15 bisect steps only to realize your script has a bug.
References
- Git Documentation: git-bisect — Official reference with all options and edge cases.
- Pro Git Book: Debugging with Git — Bisect chapter from the Pro Git book.
- Git Bisect Manual Page — Thorough technical walkthrough of bisect internals by Christian Couder.
- Binary Search Algorithm — Background on the algorithm that makes bisect efficient.
- Git Bisect Run Examples — Official examples of automated bisect with
git bisect run.
