This blog's repository holds 3,454 commits since 31 March 2026, merges excluded. Of those, 411 start with fix. So roughly one commit in eight is the moment I noticed I had broken something and came back for it.
What I was curious about today wasn't that 411. It was this: did my fixes hold? How many times did I believe I had fixed a bug, close it, and then have to return to the same place? Because a first fix turning out to be incomplete costs more than the bug itself: during that window I think the problem is solved. My attention has already moved elsewhere.
I found the number. But the first number I found was wrong, and the way it was wrong teaches more than the number does.
Every figure below belongs to a single moment: the repository at origin/main commit 96ae344e, 15 October 2026. The script that produces them is in the repo: scripts/measure-fix-recidivism.mjs.
First measurement: looking at the file level
I started with the easiest definition. If a fix commit touches a code file, and another fix commit touches that same file within the next 72 hours, count the first fix as "didn't hold."
I excluded content files from the universe — src/content/, src/assets/blog/, public/. Those are articles and images; fixing a typo in an article says nothing about my engineering judgment. What remained: 357 fix commits touching code files.
The result: 228 / 357 — 63.9%.
I nearly published that number. It sounded right, and it even had a pleasant confessional ring to it: two thirds of my fixes don't hold. Numbers that look honest are the dangerous ones, because nobody works very hard to verify them.
The delay distribution is where the definition collapsed. The median gap to the following fix was 0.843 hours. Fifty minutes. So half the cases I was calling "didn't hold" were minutes in which I was still at the keyboard, splitting one problem across three commits.
That isn't a failure. That's my commit habit. The file generate-content.ts has been touched by 94 separate fix commits — when a file gets revisited that often, two fixes meeting inside it doesn't mean one repaired the other. It only means that file is the busiest junction in the repository.
The file-level measure pretended to ask my question while measuring something else: file density.
Second measurement: following the line
The right question is this: were the lines a fix wrote later changed by another fix? Not the file, the line. That's what git blame is for.
The pipeline works like this: for each fix commit, use git diff --unified=0 to extract the ranges of lines that commit deleted. Then, for each range, run git blame --porcelain -L<a>,<b> against the commit's parent — that is, "who last wrote these lines?". If the commit that comes back is also a fix commit and is older, there's a pair: one rewrote the other's lines.
8,065 lines were blamed. The result:
167 of 357 fix commits had their lines rewritten by a later fix commit. 46.8%.
The two criteria differ in three places: what they look at (file or line), how far back they look (the file criterion has a 72-hour window, the line criterion has none), and how they attribute (shared file, or blame chain).
I should admit a trap I nearly fell into here. I was about to write "228 minus 167 means 61 cases were filtered out." That would have been wrong: the two sets aren't nested. Their intersection is 135. 93 cases appear only in the file criterion (the same file was touched within 72 hours, but the lines never met), and 32 cases appear only in the line criterion — cases the coarse measure never saw because they fall outside its 72-hour window. So the line criterion doesn't just filter; it also finds what the coarse one missed. Subtracting two totals is no substitute for comparing two sets.
What blame says, and what it doesn't
Here I had to stop and read what the tool actually claims, because the whole measurement rests on it. Git's own documentation is explicit about blame: it annotates each line with "the revision which last modified the line". Last modified. Not whoever wrote the logic.
Here is where the difference bites: say I wrote a line three months ago, an intervening chore commit fixed the indentation, and then a fix deleted the line. Blame attributes that line to the chore, not to me. The link breaks and the pair never gets counted. That effect runs one way: it only subtracts.
The same document states two more limits plainly. First, the report "does not tell you anything about lines which have been deleted or replaced." Second, line origins are followed across whole-file renames, and per the documentation there is currently no option to turn that off.
There's also an omission of my own: I didn't use the -M and -C options. The thresholds in the documentation are 20 alphanumeric characters for -M and 40 for -C. So if a line was moved within a file or copied from another, blame attributes it to the commit that moved it rather than to its origin. Which way does that shift the 167? The honest answer is "not one way": if the moving commit isn't a fix, the link breaks and the count drops; if the moving commit is a fix, a link appears that shouldn't, and the count inflates. Since only 11.9% of commits in this repository are fix commits, the breaking side should dominate — but that's probability reasoning on my part, not a measurement.
Let me be blunt about one more thing, because it would be easy to savage the file criterion and then canonise the line one: the line criterion has false positives of its own. A fix may have deleted a line where the previous fix had only touched the indentation or renamed a variable; blame counts that as a pair too. So 167 is a lower bound for my operational definition, not for the concept of "my fix didn't hold." Conflating those two turns a criterion into an instrument that confirms itself.
What the 47% is not a number for
Now the decomposition that matters. Time to first rewrite across those 167 cases:
| Window | Cases | Share of 167 |
|---|---|---|
| ≤ 1 hour | 52 | 31.1% |
| ≤ 24 hours | 75 | 44.9% |
| ≤ 7 days | 99 | 59.3% |
| All | 167 | 100% |
The median is 44.63 hours. The quartiles — by nearest-rank, because different percentile conventions give different decimals here — are 0.53 and 635.36 hours. The fastest was 1.03 minutes, the slowest 101.22 days. The distribution isn't fed from the middle; it's fed from both ends.
I don't count those 52 cases as failures. If I came back to the same line within the hour, I never thought the problem was solved; I just committed the work in pieces. The remaining 115 cases (167 − 52, a difference I worked out myself) are the real number: I had been away from the keyboard for at least an hour, I had probably said "right, that's done," and then I had to come back. Out of 357, that's 32.2%.
In 103 of the 167 cases, the rewrite lands on a different calendar day. So I had closed out the working day and opened the same line the next morning.
One third. Writing that is less striking than writing 64%, and that is exactly why it's the right number.
Two cases: five minutes and three months
Because abstract ratios can mislead, I opened one case by hand from each end of the distribution.
Five minutes. On the evening of 28 September, commit 6255cf90 was where I believed I had closed all twenty findings CodeQL had flagged; I wrote up that round in the piece about closing twenty findings and opening the twenty-first myself. One of those findings was the tag-stripping work in scripts/lib/strip-tags.mjs. The pattern I wrote was </script\s*> — I had accounted for whitespace after the name in a closing tag. Five and a half minutes later came a7b7bb60. Because a closing tag can carry more than whitespace: browsers treat </script foo=bar> as a valid close, and my pattern missed it. The fix to the fix was a lookahead condition with [^>]*>.
I'm fond of this pair, because my own sentence sits in the comment that second commit added to the code: `</script\s> is NOT ENOUGH — my first fix of 28 Sep 2026 fell short here.* The commit message says the same thing in drier words. Since this pair was known before the measurement script existed, it served as a deliberate positive for validating that script — the scan reported 6255cf90 → a7b7bb60, 5.4 minutes, shared file scripts/lib/strip-tags.mjs`. A scanner that can't find what it's supposed to find can't be trusted on what it does find.
101 days. On 8 May, with 0b9024c2, I added this to the content generation workflow: if a generated article fails the quality gate, the workflow shouldn't error out, it should skip gracefully. One of the lines I added was a condition — if: steps.preflight.outputs.skip != '1' && steps.validate.outputs.rejected != '1'. On 17 August, dcce03e6 deleted that line, because keeping a rejected topic queued wasn't atomic.
I verified the line overlap by hand: dcce03e6 deletes three lines in that file, and the 455th blames back to 0b9024c2. The mechanics are right. But read honestly, this case works against my own criterion: the first commit said "don't blow up on rejection," the second said "don't lose the topic on rejection." That isn't my first fix failing to hold; it's a new requirement arriving in the same neighbourhood. The criterion can't tell the two apart. Presenting it as the extreme of the distribution while hiding that would be dressing up the criterion's looseness as evidence for my thesis.
The missing piece: what the fix was fixing
The real gap I noticed while doing this wasn't the ratio. It was this: not one of my 411 fix commits carries a Fixes: tag.
The Linux kernel standardised this long ago. The contribution guide defines the tag: "A Fixes: tag indicates that the patch fixes a bug in a previous commit." The rationale is written down too — "It is used to make it easy to determine where an issue originated, which can help review a bug fix." The format is specific: at least the first 12 characters of the buggy commit's SHA-1 and its one-line summary, and the tag must not be split across lines. The same document also asks for a Closes: tag with the report's URL when the bug arrived as a report.
There's a subordinate clause worth respecting here, because calling this tag "mandatory" would be misreporting the document. The text is conditional: "If your patch fixes a bug in a specific commit, e.g. you found an issue using git bisect" — that is, if you know which commit holds the bug. The same passage also notes the tag does not substitute for the stable kernel rules process. The kernel's rule isn't "tag every fix"; it's "write it down when you know the origin."
I searched my own repository for the tag in kernel form: zero. (A bare grep -i "Fixes:" returns two hits, but neither is a tag — one is a section heading inside the body of a feat: commit. If you don't state the pattern you searched for, a sceptical reader can't reach your number.)
Then I asked a looser question: does any commit message reference a commit hash at all? My first scan said 27 out of 411. Looking at the results, I saw my script producing its own finding: the pattern \b[0-9a-f]{7,40}\b was mistaking GitHub Actions run IDs (31458370574), dates (20260820) and user numbers (329209900) for hashes. I tested each candidate with git rev-parse --verify to see whether it resolved to a real commit.
The real number is 12 / 411 — 2.9%. Fifteen false positives. One interesting case among them: 6a891370 has a genuine hash shape, but no such commit exists in this repository — either carried over from another repo, or a reference that no longer resolves.
So saying "none of them says what it fixed" would be unfair: twelve commits do name the origin in plain prose, and name it properly. What's missing isn't the telling; it's telling it in a form a machine can read. A human can read those twelve sentences; git log --grep can read none of the 411.
A text-scanning script generating its own finding is now the third time this has happened on this blog. My first count was inflated in the run where I compared my recipe's seven claims against the code, and again three days ago when I re-checked every external link on the blog — that time the measurement found its own mistakes three separate times. It's become a reflex: I don't write the number down until every hit from a scan resolves to a real object.
This number is not the industry's change failure rate
The urge to place a ratio somewhere is natural, but the wrong comparison kills the argument. One of DORA's four metrics is change failure rate, defined as "The percentage of deployments causing a failure in production" — the share of deployments that cause a production failure. The way it's computed rests on joining deployment counts to incident records; the documentation requires nothing of an incident record beyond containing the deployment ID, and leaves the rest to each team.
That is not what this measurement covers. What got counted here is repetition in the code's own history, not failures in production. Rewriting a fix's line may have broken nothing in production; conversely, a failure I never noticed may have sat live for months. The two metrics come close to sharing a name and do not measure the same thing. Putting 32.2% next to an industry threshold would turn the number into decoration that looks like information.
I found a similar distinction in the SRE book's postmortem chapter. The chapter says recurrence is real — without a formal learning process, incidents "may recur ad infinitum" — and one of its review criteria is "Is the action plan appropriate and are resulting bug fixes at appropriate priority?". But what caught my attention was what the chapter doesn't say: it never discusses tracking whether action items get completed, or whether the fix actually worked. The review looks at whether the plan is appropriate, not at its outcome.
My 115 cases live precisely in that gap. The plan was appropriate, the fix got written, and nobody went back to check whether it held — neither did I, until today.
What I'll do, and what I won't
What comes out of this measurement and will actually get applied is a short list. Long lists are easy to write, so I'm not writing one.
-
A
Fixes:habit. When I find a bug's origin withgit bisectorblame— that is, when the kernel's condition is met — I'll put the hash and a one-line summary in the commit body. Not on everyfix; on the ones whose origin I know. Today's rate is 2.9% and raising it costs one line. - Writing the one-hour rule into the measurement, not the behaviour. I considered squashing commits to clean up that sub-hour cohort of 52, then dropped the idea. Small commits work in my favour; fixing the criterion is the right move, not breaking the habit.
-
Flagging the junction files.
generate-content.tshas taken 94 fixes,content-generate.yml60,zero-post-deploy.test.mjs35, withtranslate-post.tsandsocial-post.tson 34 each. Of 215 distinct code files, 100 were fixed exactly once. When density is this skewed, I now have a number to justify one more look before calling a fix to those five files "done." -
Re-measuring the ratio. Doing this once and burying it in an article would turn it into another claim that goes stale. The script now lives in the repository (
scripts/measure-fix-recidivism.mjs) and its only input is git history; I'll run it again in three months and see which way the 115 moved. If you want to try it on your own repository, the definitions are readable there: how many hours the window is, how rename detection is handled, which extensions count as code.
What I won't do: turn this ratio into a quality badge. What I learned from the outage where I'd reverted in ninety seconds but stayed broken for five hours was how thin the assurance a single number gives you about a process really is. I don't know whether 32.2% is good or bad — I have no other repository to compare it against, and even if I did, its definition wouldn't match mine.
Conclusion
This run produced three numbers: 63.9%, 46.8% and 32.2%. All three came from the same repository, on the same day, out of the same git history. The only difference between them is the decision I made about what "my fix didn't hold" means.
The lesson isn't the ratio. The lesson is this: when I sat down to measure my own process, the first number I produced was the most striking one — and it was striking precisely because the criterion was loose. A loose criterion is generous; it hands you plenty of material. The tighter I made the number, the smaller the claim got, and the smaller it got, the more defensible it became.
The moment to be most careful when measuring an engineering process isn't the moment the number vindicates me. It's the moment it manages to look honest by making me look bad enough.
Official Sources
-
Submitting patches: the essential guide — Linux Kernel documentation — the definition, rationale, format and condition of the
Fixes:tag. -
git-blame Documentation — line attribution as "last modified" revision, the deleted-lines limit,
-M/-Cthresholds. - Using the Four Keys to measure your DevOps performance — Google Cloud — the change failure rate definition and its deployment/incident join.
- Postmortem Culture: Learning from Failure — Google SRE Book — recurring incidents and postmortem review criteria.
Top comments (0)