Skip to content
WestScript
Writing up a debugging story with git bisect

WestScript Editors@build-notesPosted

Writing up a debugging story with git bisect

A debugging story is one of the best posts a developer can publish, and one of the easiest to ruin. The usual draft jumps straight to the fix, skips how anyone found it, and leaves the reader with a diff and no new skill.

The search is the part people can reuse. So this post does two jobs at once: it walks through a git bisect session from start to finish, and it shows how to turn a session like that into something worth reading. To keep the output honest, we built a throwaway repo with ten commits and planted a bug in it on purpose. Every command and result below comes from running it (Git 2.50, Node 24).

Open with the symptom, not the cause

You know the ending when you sit down to write. Your reader does not, and the story works better if they find out in the same order you did. Start with what was visibly wrong and how you could tell.

Here is the setup for our planted bug. A small slugify function turns post titles into URL slugs, and it is supposed to strip accents. A title with an accent now comes through untouched:

bash
$ node check-cafe.js
café-notes        # expected: cafe-notes

Two sentences and one block of output, and the reader knows the problem and can guess whether it matters to them. Add what you suspected only if it shaped what came next. A wrong guess that cost you an hour belongs in the post. A list of every idle theory does not.

Write the check before you start searching

Bisecting needs a yes-or-no question that Git can ask of any commit. In a real project the test for your bug usually does not exist in old commits, so keep it outside the repo, where checking out history cannot remove it. Ours sits one directory up, in ../check-cafe.js:

js
const { slugify } = require(process.cwd() + '/slug');
const slug = slugify('caf\u00e9 notes');
console.log(slug);
process.exit(slug === 'cafe-notes' ? 0 : 1);

Note the escape. Spelling out \u00e9 pins the input to one exact form of the letter, which turns out to matter. Include the check in your write-up in full, because it is the piece readers will copy and adapt.

Then confirm both ends of the range before trusting anything. The first commit prints cafe-notes and exits 0; the latest prints café-notes and exits 1. If your "good" commit is secretly bad, bisect will still hand you an answer, and it will be wrong.

Let Git do the walking

Give git bisect start the bad commit first, then the good one, and hand the check to git bisect run:

bash
$ git bisect start HEAD 5ded7dc
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[524a6c3ed1a84709d577a63f1b48b06bf6850966] Name the combining marks regex

$ git bisect run node ../check-cafe.js
running 'node' '../check-cafe.js'
cafe-notes
Bisecting: 2 revisions left to test after this (roughly 1 step)
[d12544d792d3c07ca7418f8d207fe14dd0240c0d] Add license
running 'node' '../check-cafe.js'
café-notes
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[c7d5439a531bdab0180ebf50e1edf6b0e976afb6] Normalize titles to NFC for consistent storage
running 'node' '../check-cafe.js'
café-notes
c7d5439a531bdab0180ebf50e1edf6b0e976afb6 is the first bad commit
commit c7d5439a531bdab0180ebf50e1edf6b0e976afb6
Author: demo <demo@example.com>
Date:   Mon Nov 2 09:18:46 2026 +0700

    Normalize titles to NFC for consistent storage

 slug.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
bisect found first bad commit

Three test runs to search nine candidate commits. That is binary search doing its usual trick: each answer throws away about half of what remains. Run git bisect reset afterwards to return to the branch you started on.

When you write this up, paste the real output, even if it looks a little dull. Readers trust a transcript they could reproduce. Trim long stack traces if you need to, and say that you trimmed them.

The exit code is the whole contract. Zero means good, 125 means "this commit cannot be tested, skip it", and other codes from 1 to 127 mean bad. Codes above 127 stop the run. The git-bisect documentation spells this out, and it is worth a sentence in your post, since a build failure reported as a plain 1 will blame the wrong commit.

Explain the bug, then the lesson

Finding the commit is only half the story. Now open it (file headers trimmed):

bash
$ git show c7d5439 --format=
@@ -2,8 +2,8 @@ const COMBINING_MARKS = /[\u0300-\u036f]/g;

 function slugify(title) {
   return title
-    .normalize('NFD')
     .replace(COMBINING_MARKS, '')
+    .normalize('NFC')
     .toLowerCase()
     .trim()
     .replace(/\s+/g, '-');

Say what went wrong in plain words. NFD splits é into a plain e plus a separate combining accent, and the regex removes the accent. The commit stopped decomposing, so a precomposed é is one code point the regex never matches. Nothing gets stripped. (MDN's page on normalize covers the forms.)

Here is the kind of detail that makes a debugging post memorable. At the bad commit, a title that already arrives decomposed still works, because its accent is a separate character:

code
slugify('cafe\u0301 notes')   // 'cafe-notes'
slugify('caf\u00e9 notes')    // 'café-notes'

Those two strings look identical on screen. In a real project, that could explain why a bug seemed to depend on where a title was pasted from. Reverting c7d5439 on a clone of the repo made the check pass again, and "we ran it again after the fix" is a sentence every debugging post should be able to include.

Keep the lesson in proportion. The commit's message describes a sensible goal, and a one-line reorder is an easy thing to miss in review. The useful takeaway is about the missing test and the order of two calls, with nobody's name attached. Our notes on writing a post-mortem without blame go further on that tone, and clear messages like the ones in commit messages your future self can read are what make a bisect result readable the moment it lands.

Include the part where it got messy

Tidy sessions are rare, and an honest post says what can go wrong. To see one failure mode, we reran the search with a wrapper script that exits 125 on one commit, as if it failed to build. That commit happened to sit right next to the culprit, and Git said so plainly:

code
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
524a6c3ed1a84709d577a63f1b48b06bf6850966
c7d5439a531bdab0180ebf50e1edf6b0e976afb6
We cannot bisect more!

So skipping is not free. You may end with a short list instead of one commit, and then your post should say how you settled it, usually by reading those diffs. Other wrinkles that deserve a line if you hit them:

  • A flaky test poisons the search, because one wrong answer sends bisect down the wrong half. Run the check several times per commit if you have any doubt.
  • Busy merge history can land you on a commit inside a feature branch. git bisect start --first-parent stays on the main line.
  • Hunting a change that is not a bug, like a slowdown? --term-old and --term-new let you rename "good" and "bad" to fit.

A shape you can reuse

Strip out the Unicode and the structure of this post works for most debugging stories:

  1. The symptom, with real output.
  2. The check that tells good from bad.
  3. The search, as a transcript.
  4. The cause, in plain words, with the smallest diff.
  5. Proof the fix worked, and whatever stayed messy.

Add a regression test before you publish, and link to it if the repo is public. Then the post ends where a reader wants it to: the bug is gone and they know how to find the next one. If you have a bug that took far too long to find and want to write it up here, ask for a writer invite on the contact page.

Discussion 0

Replies are reviewed before they appear.

Write a reply

Plain text, up to 2,000 characters. No account or email needed; a moderator reviews each reply first.

No replies yet. Found a typo, a better approach or a step that failed for you? Start the thread.