Find the Commit That Broke Your Build with git bisect
Stop checking out commits by hand. Automate the search with a test script and let git bisect run pinpoint the exact commit that introduced a regression.
What you'll build
You'll write a small test script that git can run against any commit, then hand it to git bisect run so git automatically checks out commits in a binary search pattern until it finds the exact one that introduced the regression. On a repo with a few hundred commits, this takes minutes instead of an afternoon of manual checkouts.
Prerequisites
- Git 2.x (bisect has been stable for years, but 2.30+ if you want to be safe)
- A local clone of the repo with full history (
git clonewithout--depth, or rungit fetch --unshallowfirst if you cloned shallow) - A way to reproduce the bug from the command line: a test suite, a build command, a curl request, whatever proves "broken" vs "working"
- A known-good ref (a tag, a commit SHA, or
HEAD~50) where things definitely worked
This works the same on macOS, Linux, and Windows (Git Bash/WSL). The examples below use a Node project with npm run build as the check, but swap in make, pytest, go build, whatever applies to you.
Step 1: Confirm the bug and find a known-good commit
First, verify the bug actually reproduces right now:
npm install
npm run build
If that fails, good, you've confirmed HEAD is bad. Now find a commit you're confident was fine. A recent release tag is ideal:
git tag --list
git checkout v1.2.0
npm install
npm run build
git reset --hard
git checkout main
That git reset --hard before switching back matters: npm install almost always rewrites package-lock.json (and sometimes package.json on older npm versions) even on an old tag, and Git will refuse the checkout back to main with "Your local changes to the following files would be overwritten by checkout" if you skip it. If v1.2.0 builds cleanly, that's your "good" boundary.
Step 2: Write a test script git bisect can run
git bisect run needs a script that exits 0 for good and any non-zero code (except 125) for bad. Exit 125 tells git "can't test this commit, skip it" - useful when a commit predates a file your build needs, or the build system itself changed shape.
Create bisect-test.sh in the repo root:
#!/usr/bin/env bash
# Skip commits where package.json doesn't exist yet
if [ ! -f package.json ]; then
exit 125
fi
npm install --silent
install_status=$?
npm run build --silent
build_status=$?
# npm install almost always rewrites package-lock.json (and sometimes
# package.json on older npm versions). That leaves the working tree dirty,
# and when git bisect tries to check out the next commit it fails with
# "Your local changes to the following files would be overwritten by
# checkout", which kills the whole run dead in its tracks. Force the tree
# clean before this script exits. bisect-test.sh itself is untracked, so
# `git reset --hard` won't delete it out from under you.
git reset --hard
if [ "$install_status" -ne 0 ] || [ "$build_status" -ne 0 ]; then
exit 1
fi
exit 0
Make it executable:
chmod +x bisect-test.sh
Notice there's no set -e here. If the script bails the instant npm run build fails, it never reaches the git reset --hard line, and you're back to the dirty-checkout problem. Capture the exit codes, clean the tree unconditionally, then decide the final exit status. If your check is a test suite instead of a build, swap the npm run build --silent line for something like npm test or pytest -q, but keep the same pattern: run it, record the status, reset, then exit.
One more gotcha: don't commit this script to the branch you're bisecting unless you want it checked out (and possibly missing) on old commits. Keep it untracked, or stash it, or reference it via an absolute path outside the repo. Untracked files survive git reset --hard just fine, which is exactly why this approach works.
Step 3: Start the bisect session
git bisect start
git bisect bad HEAD
git bisect good v1.2.0
Git checks out a commit roughly halfway between the two and drops you in detached HEAD state. You could test manually from here and call git bisect good or git bisect bad each time, but that's the slow path.
Step 4: Let git bisect run drive the search
git bisect run ./bisect-test.sh
Git checks out a commit, runs the script, reads the exit code, and picks the next commit automatically, repeating until only one candidate is left. You'll see output like:
running ./bisect-test.sh
Bisecting: 12 revisions left to test after this (roughly 4 steps)
...
abc1234 is the first bad commit
That last line is the answer. Git also prints the commit message and diff stat for that commit.
Step 5: Inspect and clean up
Look at what actually changed:
git show abc1234
When you're done, get back to a normal branch state:
git bisect reset
Skipping this step leaves you in detached HEAD, which is confusing later.
Verify it works
Check out the commit right before the flagged one and confirm the build passes, then check out the flagged commit and confirm it fails:
git checkout abc1234~1 && npm run build # should succeed
git checkout abc1234 && npm run build # should fail
If both match expectations, you've got your culprit. Go back to your branch with git checkout main.
Troubleshooting
- "bisect run failed: exit code -1" or similar: your script path is wrong or not executable. Double check
chmod +xand that you're calling it with./or an absolute path. - "Your local changes to the following files would be overwritten by checkout": your test script (or a manual step like Step 1) left the working tree dirty, usually from
npm installtouching the lockfile, and git can't move to the next commit. Addgit reset --hardright before the checkout or right before the script exits, so the tree is always clean when git tries the next commit. - Bisect keeps landing on unbuildable commits: this usually means the build tooling itself changed (new lockfile format, renamed script). Add more guard clauses to your script that
exit 125for those states rather than treating them as bad. - Results look wrong / non-deterministic: your test script might be flaky (network calls, timing-dependent tests). Make it deterministic, or add retries inside the script before it reports failure.
- You bisected into merge commit weirdness: if history has a lot of merges, consider adding
--first-parenttogit bisect startso git only walks mainline commits, which is usually what you want for a broken main branch.
Next steps
Run git bisect log any time mid-session to see the good/bad history so far, and git bisect replay <logfile> to resume a saved session later. For flaky checks, look at git bisect skip to manually bail on a commit without breaking the search. If you bisect the same class of regression often, keep bisect-test.sh around in a scripts folder outside version control and parameterize it so future you doesn't rewrite it from scratch.
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 0
No comments yet
Be the first to weigh in.