Skip to content
Codeloom
Git

Git Bisect: Find the Commit That Introduced a Bug

Learn how to use git bisect to perform binary search through your commit history and pinpoint the exact commit that introduced a bug in your codebase.

·9 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How git bisect performs binary search through commits
  • Running bisect manually with good and bad markers
  • Automating bisect with a test script
  • Real-world debugging workflows with bisect

Prerequisites

  • Basic Git commands (commit, log, checkout)
  • Familiarity with the command line

Why Finding the Right Commit Matters

You are working on a project and suddenly a feature that worked last week is broken. The test suite passes on an older tag but fails on main. Somewhere in between, a commit introduced the regression. Scrolling through git log and checking commits one by one is painfully slow, especially when there are hundreds of commits in the range.

git bisect solves this by applying binary search to your commit history. Instead of checking every commit linearly, it halves the search space with each step. For 1,000 commits, you only need about 10 steps instead of 1,000.

How Git Bisect Works

The algorithm is straightforward:

  1. You tell Git a “bad” commit (where the bug exists) and a “good” commit (where everything worked).
  2. Git checks out the commit in the middle of that range.
  3. You test that commit and tell Git whether it is “good” or “bad.”
  4. Git narrows the range and checks out the next midpoint.
  5. This repeats until Git identifies the single commit that introduced the problem.

Mathematically, for n commits between good and bad, you need at most log2(n) steps.

Starting a Basic Bisect Session

Let’s walk through a hands-on example. Suppose your test suite fails on HEAD but worked fine at tag v2.1.0.

# Start the bisect session
git bisect start

# Mark the current commit as bad
git bisect bad

# Mark the known good commit
git bisect good v2.1.0

Git responds with something like:

Bisecting: 47 revisions left to test after this (roughly 6 steps)
[a3b8c9d] Refactor user authentication module

Git has checked out a commit halfway between v2.1.0 and HEAD. Now you test this commit.

Testing Each Step

At each midpoint, run whatever test reveals the bug. This could be a unit test, a build command, or a manual check.

# Run your test
npm test

# If the test passes, this commit is good
git bisect good

# If the test fails, this commit is bad
git bisect bad

After each response, Git checks out the next midpoint and reports how many steps remain:

Bisecting: 23 revisions left to test after this (roughly 5 steps)
[f7e2a1b] Add caching layer for API responses

Continue marking commits as good or bad until Git finds the culprit:

b4d5c6e7 is the first bad commit
commit b4d5c6e7
Author: Jane Developer <jane@example.com>
Date:   Mon Jun 15 14:30:00 2026 -0500

    Update database query for user profiles

 src/db/queries.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

Now you know exactly which commit broke things and can inspect the diff.

Ending the Bisect Session

When you are done, reset your working tree back to where you started:

git bisect reset

This checks out the branch you were on before bisecting. Always remember to do this, or you will be left in a detached HEAD state.

Viewing the Bisect Log

You can review the steps you took during a bisect session:

git bisect log

This outputs something like:

git bisect start
# bad: [abc1234] HEAD commit message
git bisect bad abc1234
# good: [def5678] v2.1.0
git bisect good def5678
# good: [a3b8c9d] Refactor user authentication module
git bisect good a3b8c9d
# bad: [f7e2a1b] Add caching layer for API responses
git bisect bad f7e2a1b

You can save this log to replay a session later:

git bisect log > bisect.log

# Replay the session
git bisect replay bisect.log

Automating Bisect with a Script

The real power of git bisect comes from automation. Instead of manually testing each commit, you provide a script that returns exit code 0 for good and non-zero for bad.

git bisect start HEAD v2.1.0
git bisect run npm test

Git will automatically run npm test at each midpoint and mark commits based on the exit code. The entire process completes without any manual intervention.

Writing a Custom Test Script

Sometimes you need a more specific test. Create a script that checks for the exact condition:

#!/bin/bash
# bisect-test.sh

# Build the project first
make build 2>/dev/null
if [ $? -ne 0 ]; then
    # Build failure - skip this commit (exit code 125)
    exit 125
fi

# Run the specific failing test
python -m pytest tests/test_user_profile.py::test_query_returns_results

The exit code 125 is special. It tells git bisect to skip this commit because it cannot be tested (for example, it does not compile). Git will try a neighboring commit instead.

Make the script executable and run it:

chmod +x bisect-test.sh
git bisect start HEAD v2.1.0
git bisect run ./bisect-test.sh

Using Inline Commands

For simple checks, you can pass the command directly:

# Find when a file was deleted
git bisect start HEAD v2.1.0
git bisect run test -f src/utils/helper.py

# Find when a string disappeared from a file
git bisect start HEAD v2.1.0
git bisect run grep -q "def calculate_tax" src/billing.py

Handling Untestable Commits

Sometimes a commit in the middle of your range cannot be tested. Maybe it does not compile or the test infrastructure is broken at that point. You have two options.

Skip a Single Commit

git bisect skip

Git picks a nearby commit to test instead.

Skip a Range of Commits

If you know an entire range is untestable:

git bisect skip v2.1.5..v2.1.8

Be aware that skipping too many commits can prevent Git from narrowing down to a single commit. It may report a range of suspects instead of one definitive answer.

Using Bisect with Terms

By default, bisect uses “good” and “bad,” but sometimes those labels do not make sense. If you are looking for when a performance improvement was introduced (not a bug), the commit you want is “good” in the conventional sense. Git lets you define custom terms:

git bisect start --term-old=slow --term-new=fast

git bisect fast HEAD
git bisect slow v2.0.0

# At each step
git bisect slow  # this commit is still slow
git bisect fast  # this commit is fast

This avoids the mental confusion of calling a “good” change “bad.”

Real-World Example: Finding a Performance Regression

Suppose your API response time jumped from 50ms to 300ms. You can automate bisect with a performance check:

#!/bin/bash
# perf-bisect.sh

# Start the application in the background
python app.py &
APP_PID=$!
sleep 2

# Measure response time
RESPONSE_TIME=$(curl -o /dev/null -s -w '%{time_total}' http://localhost:8000/api/users)

# Kill the app
kill $APP_PID 2>/dev/null

# Convert to milliseconds and compare
MS=$(echo "$RESPONSE_TIME * 1000" | bc | cut -d. -f1)

if [ "$MS" -lt 100 ]; then
    exit 0  # Good - fast response
else
    exit 1  # Bad - slow response
fi
git bisect start HEAD v2.0.0
git bisect run ./perf-bisect.sh

Bisect in a CI/CD Context

You can integrate bisect into your CI pipeline for automated regression detection:

# .github/workflows/bisect-regression.yml
name: Bisect Regression
on:
  workflow_dispatch:
    inputs:
      bad_commit:
        description: 'Bad commit SHA'
        required: true
      good_commit:
        description: 'Good commit SHA'
        required: true
      test_command:
        description: 'Test command to run'
        required: true
        default: 'npm test'

jobs:
  bisect:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Run git bisect
        run: |
          git bisect start ${{ inputs.bad_commit }} ${{ inputs.good_commit }}
          git bisect run ${{ inputs.test_command }}

Tips for Effective Bisecting

Keep commits small and atomic. If each commit changes one thing, bisect pinpoints the exact change. Massive commits that touch 50 files make the result less useful even when you find the right commit.

Use descriptive commit messages. When bisect finds the offending commit, a message like “fix stuff” gives you nothing. A message like “Update SQL query to use LEFT JOIN for user profiles” tells you exactly what changed.

Fetch the full history. Bisect needs access to all commits in the range. If you did a shallow clone, deepen it first:

git fetch --unshallow

Stash or commit local changes before starting. Bisect checks out different commits, which will conflict with uncommitted work:

git stash
git bisect start HEAD v2.1.0
# ... do bisect ...
git bisect reset
git stash pop

Combine with git show and git diff. Once bisect identifies the commit, inspect it thoroughly:

# Show the full commit with diff
git show b4d5c6e7

# Compare the commit with its parent
git diff b4d5c6e7^..b4d5c6e7

Common Pitfalls

Forgetting git bisect reset. This leaves you in a detached HEAD state. If you accidentally close the terminal, just run git bisect reset in a new session.

Flaky tests. If your test sometimes passes and sometimes fails on the same commit, bisect will give wrong results. Make sure the test is deterministic before automating.

Merge commits. Bisect follows the first-parent history by default. If your history has many merge commits, bisect may check out states that never existed on any branch. Use git bisect start --first-parent to follow only the mainline.

git bisect start --first-parent HEAD v2.1.0

Wrapping Up

git bisect turns the problem of finding a regression from a linear search into a logarithmic one. For manual sessions, you mark commits as good or bad while Git narrows the range. For automated sessions, you hand Git a test script and let it do everything.

The key steps are: start a session with git bisect start, mark known endpoints with good and bad, test each midpoint or automate with git bisect run, and finish with git bisect reset. Combined with atomic commits and good test coverage, bisect becomes one of the most powerful debugging tools in your Git workflow.