← Back to Blog
7 min read
Share

What Six Months of AI Commits Looks Like in git blame

What Six Months of AI Commits Looks Like in git blame

I went back through six months of history on a side project where most of the code was generated. Not to feel good about it — to find out which parts I'd since had to rewrite, and whether anything in the original commits predicted it.

Some of what I found was obvious in hindsight. Some of it wasn't.

The shape of the history

The first thing that jumps out isn't quality, it's commit size. My pre-AI commits on comparable projects averaged something like 40 lines. The generated ones averaged closer to 250, with a long tail of 800+ line commits that added an entire feature in one shot.

That's not automatically bad. A 250-line commit that adds one coherent feature is fine. But it changed the character of the history in a way I didn't anticipate: git bisect stopped being useful. When a regression lands inside an 800-line commit, bisect narrows it to "somewhere in this feature," which you already knew. The tool depends on commits being small enough that identifying one is identifying the bug.

I only noticed this the first time I actually needed to bisect, four months in, and by then the history was full of boulders.

What held up

Roughly two-thirds of the generated code was never touched again, which is the honest baseline for any code — most code just sits there working. The parts that held up best clustered in ways that make sense in retrospect:

Pure functions with obvious inputs and outputs. Formatters, parsers, validators, date math, a diff implementation. Anything where the spec is fully contained in the function signature came out right and stayed right. There's no context to miss.

Code that had a test in the same commit. Not because the tests caught bugs — most of them never failed — but because asking for a test forces the edge cases into the conversation. The commits with tests have visibly different implementations: they handle the empty array, the zero, the null. The ones without handle the happy path.

Second-attempt code. Anything where the commit message says "rework" or "fix" or where I'd clearly gone back and forth. Unsurprising, but worth stating: the first output was rarely the one that survived, and the difference between projects that go well and projects that don't is largely whether you treat the first output as a draft.

What didn't

Anything touching configuration. Environment variables, feature flags, build settings, deploy config. These were rewritten at a much higher rate than anything else, and the reason is structural: config is where a codebase's specific, arbitrary, undocumented reality lives. There's no way to infer that this project's staging environment has a different Redis URL format. Generated config is a guess dressed up as an answer.

Error handling added after the fact. There's a clear pattern in the history: a feature lands, works, and three commits later there's a "add error handling" commit that wraps things in try/catch. Almost all of those got rewritten later, because bolted-on error handling catches errors at the wrong altitude — at the call site instead of where you can actually do something about it. The features where failure behaviour was specified up front didn't have this problem at all.

The third integration with the same service. The first time I integrated with a given API, the generated code was fine. The second time it was fine. By the third, I had three slightly different clients — different retry logic, different error shapes, different auth handling — that all worked and none of which matched. Nothing was wrong. It was just three of a thing that should have been one, and consolidating them was a real afternoon.

This is the failure mode nobody warns you about, because each individual commit is defensible. It only becomes a problem in aggregate, and aggregates don't show up in code review.

The correlation I didn't expect

I tagged every commit as "still standing" or "rewritten" and looked for signals in the original commit that predicted it. The strongest one wasn't complexity, or length, or the area of the codebase.

It was whether the commit introduced a new file or edited an existing one.

Commits that only edited existing files held up at a much higher rate than commits that created new ones. My read: editing an existing file means the surrounding code is right there, and the patterns in it constrain the output. Creating a new file means starting from the general case — the median implementation, the one that isn't specific to this project.

The practical version of this: when I want something new, I've started asking for it to be added to an existing file first, even if it obviously belongs somewhere else eventually. Let it be shaped by its neighbours, then move it. Moving a working function is a two-minute operation. Fixing one that was written in a vacuum isn't.

The comment thing

An unscientific but consistent observation: generated code has roughly three times the comment density of what I write by hand, and the comments explain what rather than why.

// Loop through the items and calculate the total
for (const item of items) {
  total += item.price;
}

This is noise. It doesn't say anything the code doesn't. Six months in, my repo had thousands of lines of it, and the real cost isn't the noise itself — it's that when a genuinely useful comment appears (why we're rounding here, why this endpoint retries and that one doesn't), it's camouflaged among fifty comments that restate the obvious. The signal-to-noise ratio dropped low enough that I'd started skimming past comments entirely, which is exactly the habit you don't want.

It's now in my project instructions: comments explain why, never what. That one line noticeably changed the output.

What I actually changed

Four things, all small:

Commits stay under ~200 lines. If a feature is bigger, it lands in stages. Costs a couple of extra minutes, keeps bisect working, and makes review possible — an 800-line diff doesn't get reviewed, it gets skimmed.

Failure behaviour is in the request, not a follow-up. "Fail open, log it" or "throw, the caller handles it" as part of the initial spec. Error handling designed in is different code from error handling added on.

New code goes into an existing file first. Then gets moved once it works.

A monthly pass for duplicates. Fifteen minutes with grep looking for three-of-a-thing. This is the only one that requires discipline rather than habit, and it's the one that pays best.

The part that surprised me most

None of the problems I found were quality problems in the individual commits. The code was fine. It was well-formed, it worked, and most of it I'd have signed off on in review — I did sign off on it, that's how it got there.

Every real problem was an aggregate problem: three clients where there should be one, comment noise that made comments useless, commits too large to bisect, config that was subtly wrong in the same way in six places. These are all invisible at the commit level and obvious at the six-month level.

Which suggests the review that matters isn't the one you do when the code is generated. It's the one you do a month later, looking at what accumulated. I don't have a great process for that yet. Fifteen minutes with grep and a bad mood is what I've got, and it's found something every single time.

Related: Vibecoding and technical debt · Evaluating AI code quality · Reading AI code

Get the good stuff

New tools and posts, occasionally. No spam.