DevFox Labs
HomeLearningToolsAboutContact
advanced35 minby DevFox

Finding Regressions with git bisect

Binary-search your history to the exact commit that broke something, then automate it with git bisect run, the exit-code contract, skip, terms, and replay.

  • Git

On this page

  • A binary search over your history
  • A session from start to finish
  • Commits you cannot judge
  • Letting Git do the testing
  • A test script that tells the truth
  • Searching for things that are not bugs
  • Saving, replaying, and narrowing the search
  • What makes a history bisectable
  • Once you have the culprit
  • When bisect is the wrong tool
  • A cheat sheet to keep
  • Where to go next
DevFox Labs

Structured lessons and courses for developers who care about craft.

Platform

HomeLearningAll lessonsAboutContact

Legal

Terms of ServicePrivacy PolicyCookie Policy

© 2026 DevFox Labs. All rights reserved.

    The bug is real, the test proves it, and the last release was fine. Somewhere in the two thousand commits since then, one of them broke checkout. Reading them all is not a plan.

    Git can find that commit for you in about eleven tests. By the end of this lesson you will run a bisect by hand, hand the whole search to a script so it finishes while you are in a meeting, and know which kinds of history make the technique work.

    A binary search over your history

    You already know how to find a word in a dictionary: open the middle, decide which half the word is in, throw the other half away, repeat. Bisecting is that same move applied to commits. Git checks out a commit halfway between a known-good one and a known-bad one, you say which side it landed on, and half the remaining history disappears.

    The arithmetic is the whole reason this matters. Each answer halves the range, so the number of tests is the base-2 logarithm of the range size. A thousand commits is ten tests. Ten thousand commits is about fourteen. A hundred thousand is seventeen. Doubling your project's history costs you exactly one more test — your effort stays almost flat while the haystack grows without limit.

    The one thing bisect assumes

    Binary search only works if the property you test is monotonic: every commit before the culprit is good, every commit from the culprit onward is bad. If the bug appears, gets accidentally masked, and returns later, bisect still converges — but on a transition point, not necessarily the one you want. Check that you are hunting a single clean flip from working to broken.

    A session from start to finish

    Start by telling Git you are searching, then give it the two endpoints. HEAD is broken; the v4.2.0 tag was the last release nobody complained about:

    bash
    git bisect start
    git bisect bad          # no argument means "HEAD is bad"
    git bisect good v4.2.0

    With one bad and one good endpoint, Git makes the first jump:

    text
    Bisecting: 987 revisions left to test after this (roughly 10 steps)
    [8f2c1ab9d3e7c05a1b6f4d2e9c8a7b3f5d1e0c92] Cache tax rates per region

    Your working tree is now that commit. Build it, run the failing scenario, and report back with git bisect good or git bisect bad — no hash needed, Git knows where you are. Each answer prints a new midpoint and a smaller step count. Repeat until the count reaches zero:

    text
    $ git bisect bad
    3d9b7e14c8a2f6b09d5e3c17a4b8f2e6d0c9a1b7 is the first bad commit
    commit 3d9b7e14c8a2f6b09d5e3c17a4b8f2e6d0c9a1b7
    Author: Priya Raman <priya@example.com>
    Date:   Tue Mar 4 11:02:13 2025 +0000
    
        Normalise discount codes before lookup
    
     src/checkout/discount.ts | 14 ++++++++------
     1 file changed, 8 insertions(+), 6 deletions(-)

    That is the answer: a hash, a subject line, and the files it touched. Now end the session and return to where you started:

    bash
    git bisect reset

    Always reset when you are done

    A bisect leaves you on a detached HEAD at some commit from months ago. Forget git bisect reset and your next commit lands on no branch at all, where it is easy to lose — meanwhile your editor, dev server, and dependencies are all running against ancient code. Reset returns you to the branch you started on and clears the bisect state.

    Once this is familiar, collapse the opening into one line. git bisect start <bad> <good> takes both endpoints and jumps straight to the first midpoint:

    bash
    git bisect start HEAD v4.2.0

    Commits you cannot judge

    Sometimes the midpoint will not build, or the feature you are testing did not exist yet. Calling it good or bad is a lie that sends the search down the wrong half. Say so instead:

    bash
    git bisect skip

    Git picks a different commit near the same point and carries on. If you already know a whole stretch is unbuildable — a week-long refactor, say — skip the range in one go:

    bash
    git bisect skip v4.3.0..v4.3.4   # exclusive of v4.3.0, inclusive of the tip

    If too much gets skipped, Git gives up honestly with "there are only skipped commits left to test": the culprit is inside the untestable region and bisect cannot narrow it further. Widen what counts as a test, fix the build there, or read the remaining handful of diffs by hand.

    Letting Git do the testing

    Everything so far is the manual version, and the manual version is the one to stop doing. If you can express "is this commit broken?" as a command that exits non-zero on failure, git bisect run performs the whole search unattended:

    bash
    git bisect start HEAD v4.2.0
    git bisect run npm test -- -t "checkout total"

    Git checks out a commit, runs your command, reads the exit code, marks the commit, moves on — dozens of build-and-test cycles with no input from you, ending in the same is the first bad commit report.

    The contract is the exit code, and it is worth memorising:

    • 0 — the commit is good.
    • 1 through 124, plus 126 and 127 — the commit is bad.
    • 125 — cannot test this commit; treat it as a skip.
    • 128 or above — abort the bisect entirely.

    Exit 125 is the one that saves you

    A build failure is not a bug report. If your script exits 1 when compilation breaks, Git marks that commit bad and happily searches the wrong half — you will get a confident, wrong answer. Exit 125 whenever the commit cannot be judged, and Git skips it instead.

    A test script that tells the truth

    A bare npm test rarely captures the real question. Write a small script that installs, builds, runs exactly one test, and maps every outcome onto the right exit code:

    bash
    #!/usr/bin/env bash
    # /tmp/bisect-checkout.sh — deliberately outside the repository
    
    set -u
    
    # A broken build says nothing about the bug: skip, do not blame.
    npm ci --silent      || exit 125
    npm run build        || exit 125
    
    # This test flakes about one run in twenty. Only call the commit
    # bad if it fails every attempt.
    for _ in 1 2 3; do
      npm test --silent -- -t "checkout total" && exit 0
    done
    
    exit 1

    Make it executable and hand it over:

    bash
    chmod +x /tmp/bisect-checkout.sh
    git bisect start HEAD v4.2.0
    git bisect run /tmp/bisect-checkout.sh

    Never keep the script in the repository

    Bisect checks out old commits, and your script is a file like any other. Kept in the working tree, it silently reverts to an older version or vanishes partway through the run, and every commit after that is judged by the wrong code — or by nothing. Keep it in /tmp or your home directory, and do the same with any fixtures it depends on.

    Searching for things that are not bugs

    "Good" and "bad" are only labels, and they fit badly when you ask when a page got slow or when a half-finished feature first worked. Rename them with --term-old and --term-new at start time:

    bash
    git bisect start --term-old broken --term-new fixed
    git bisect fixed              # it works today
    git bisect broken v4.2.0      # it did not work at the release

    From then on you answer with git bisect broken and git bisect fixed, and git bisect terms reminds you which pair is in force. For a performance hunt, --term-old fast --term-new slow reads the way you think, and pairs well with a run script that fails when a benchmark crosses a threshold.

    Saving, replaying, and narrowing the search

    A bisect session is a sequence of decisions, and Git keeps a transcript you can save and replay:

    bash
    git bisect log > /tmp/checkout-hunt.log
    git bisect replay /tmp/checkout-hunt.log

    Hand that file to a colleague, or use it after a mistake: delete the wrongly marked line from the log, reset, and replay — far quicker than starting over. To see the range still under suspicion, run git bisect visualize (or view), which opens gitk when a display is available and falls back to git log otherwise. It takes log options, so git bisect visualize --oneline works well over SSH.

    You can also narrow what Git considers in the first place. Pass paths after -- and only commits touching them are candidates:

    bash
    git bisect start HEAD v4.2.0 -- src/checkout/ src/pricing/

    On a monorepo this can cut two thousand candidates to eighty — seven tests instead of eleven. Use it only when you are sure the bug lives in that subtree; a change elsewhere that broke checkout is invisible to this search.

    What makes a history bisectable

    Real history is not a straight line, and bisect does not need one. It works over the commit graph, picking the commit that best splits the set reachable from bad but not from good — a set that includes merged branches. So bisect will happily check out a commit that only ever existed on someone's feature branch.

    When merges make the answer useless

    With long-lived branches, landing inside one often means landing on a commit that never built on its own. Add --first-parent to git bisect start and Git considers only mainline commits — the merges themselves. The answer is coarser, "this pull request broke it", which is usually what you wanted anyway.

    This is where the commit hygiene from the intermediate course pays a dividend you can measure.

    Commits that build

    Every one is a halving.

    Ten steps over a thousand commits. And a small, self-contained commit means the culprit you get back is a twenty-line diff you can read.

    Commits that do not

    Every one is a skip.

    A skip widens the uncertainty rather than narrowing it, and a run of them can leave you with a range instead of an answer.

    A flaky test

    Sends the search into the wrong half.

    One spurious failure marks a good commit bad, and every step after that is wasted. Require several consecutive failures in the script before calling a commit bad.

    Bisect is the one tool that turns commit discipline into a number you can feel

    Once you have the culprit

    Start by reading it. The hash on its own is not the point; the diff is:

    bash
    git show 3d9b7e1

    Nine times out of ten the mistake is obvious once you see it in isolation. If you need the pressure off now, undo it with a new commit rather than rewriting anything:

    bash
    git revert 3d9b7e1

    Then close the loop: write a test that fails on the bad commit and passes with the fix, and commit it alongside. A bisect that ends without a regression test means the next person runs the same search again.

    When bisect is the wrong tool

    Bisect answers "when did behaviour change?" Other questions have cheaper answers.

    When you know the text involved — a function name, a config key, a magic string — reach for the pickaxe. git log -S finds commits that changed how many times a string appears, so it locates where something was introduced or deleted; -G takes a regex and matches any diff touching it:

    bash
    git log -S "MAX_DISCOUNT" --oneline
    git log -G "parseFloat\(.*discount" --oneline

    When you care about one stretch of one file, git log -L gives you the history of those lines alone, diff by diff, following the code as it moves:

    bash
    git log -L 40,68:src/checkout/discount.ts

    And when you only want to know who last touched a line, git blame is instant where bisect takes minutes. Use blame to form a hypothesis; use bisect when blame points at a line that looks perfectly innocent.

    A cheat sheet to keep

    bash
    git bisect start                    # begin a session
    git bisect bad                      # current commit is broken
    git bisect good v4.2.0              # this ref was fine
    git bisect start HEAD v4.2.0        # both endpoints in one line
    git bisect skip                     # cannot judge this commit
    git bisect skip v4.3.0..v4.3.4      # skip a whole range
    git bisect run <cmd>                # automate: 0 good, 125 skip
    git bisect run /tmp/check.sh        # script must live outside the repo
    git bisect start --first-parent     # mainline commits only
    git bisect start HEAD v4.2.0 -- src # limit to a subtree
    git bisect terms                    # show the current term pair
    git bisect log > /tmp/hunt.log      # save the session
    git bisect replay /tmp/hunt.log     # restore or share it
    git bisect visualize --oneline      # see what is still suspect
    git bisect reset                    # end and return to your branch
    git show <hash>                     # read the guilty diff
    git revert <hash>                   # undo it with a new commit
    git log -S "STRING" --oneline       # when a string appeared or left
    git log -G "regex" --oneline        # diffs matching a pattern
    git log -L 40,68:path/to/file.ts    # history of a line range
    git blame path/to/file.ts           # who last touched each line

    Where to go next

    You can now turn a bug report and a release tag into a commit hash, whether the gap between them is fifty commits or fifty thousand — and hand that search to a script while you get on with something else.

    The natural next step is rewriting history at scale — git rebase in its more powerful forms and git filter-repo, reshaping thousands of commits at once. That lesson is the other half of this one: bisect rewards a clean, always-building history, and rewriting is how you produce one.

    The best practice is a real hunt. Take a repository you know, pick a behaviour that changed at some point, and bisect for it twice: once by hand to feel the halving, and once with a run script to see how little of your attention it costs the second time.