Every governance conversation about AI-written code starts with the same question and the same shrug: how much of this codebase did a model write? The usual answers are guesses — stylometry, comment density, suspiciously uniform formatting. All of them are inference, and all of them are wrong often enough to be unusable in an audit.
There is a better source, and it is already in your repository. Coding assistants sign their own work:
feat(auth): reject expired refresh tokens
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
That is not something we inferred about the commit. It is something the tool stated about itself, at the time, in a structured field. Vendor and model become a parsing problem rather than a detection problem.
Where the trailers come from
Trailers are the RFC-822-style key-value block at the end of a commit message, the same convention Signed-off-by: has used since the kernel adopted the Developer Certificate of Origin. Git treats them as structured data — git interpret-trailers exists precisely to read and write them.
Assistants use three keys in practice:
Co-Authored-By:— the dominant one, because GitHub renders it as a co-author on the commitAssisted-By:— used by some agent frameworks that do not want to claim authorship- A
Generated with [Tool]line, which names the product rather than the model
The identity itself varies in how much it tells you. Anthropic's tooling writes a model name (Claude Opus 4.8, Claude Fable 5), often with a qualifier in parentheses for context window or variant. Most other vendors write only the product — Copilot, Cursor, Devin — which means "which model" has no answer for those commits. An empty model field is a real result, not a parse failure, and it should be stored as one.
The bug that hides all of it
Here is the part that catches most homegrown tooling. A commit-log extractor written for changelogs or release notes usually formats like this:
git log --format="%h %s"
%s is the subject — the first line. Every trailer lives in the body. A tool built on %s sees precisely zero attributed commits and reports, confidently, that no AI wrote any of your code.
The fix is to read the body:
git log --format="%H%n%ci%n%B%n---END---" --numstat
%B is the raw body including trailers, %ci the committer date, and --numstat gets you additions and deletions per commit so the answer can be about lines rather than commit counts. Pick a record separator that will not appear in a commit message; ---END--- is fine until someone writes it in a message, so something with a nonce is safer.
Then match the trailer lines, anchored to line starts so a trailer quoted inside prose does not count:
(?im)^\s*(?:co-authored-by|signed-off-by|assisted-by)\s*:\s*([^<\n]+?)\s*(?:<([^>]*)>)?\s*$
Across six repositories we ran this over, it found 361 attributed commits and nine distinct model identities. The counts reproduced exactly, per repository and in total, against a manual audit — which is the property you want from a metric that is going to end up on a compliance dashboard.
The number is a floor, and you must say so
This is where the honest version diverges from the marketable one.
Attribution is per commit, not per line. A commit carrying an assistant trailer contributes all of its added lines to the attributed total. If a human later rewrote half of that code in a subsequent commit, nothing subtracts it. The metric says lines added by commits an assistant co-authored, and any label shorter than that is overclaiming.
It runs the other way too. If two assistants co-author one commit, that commit counts once toward "how much of this is AI-touched" but once per assistant in the per-model breakdown. Summing the per-model rows will therefore exceed the total. That is not a bug to fix by normalising; "how much did this specific model touch" and "how much of this is AI-touched at all" are different questions and deserve different denominators.
Name the fields accordingly. attributed_additions invites the right reading. ai_written_lines does not.
The convention that erases your own data
The most useful thing this exercise produced was not a number. It was discovering which of our own conventions destroy the signal.
Plenty of teams run a commit wrapper that strips or rewrites trailers — to keep messages tidy, to avoid noisy co-author avatars on GitHub, or because a linter enforces a message format that has no trailer block. Every commit made under such a convention is invisible to this metric. Our own numbers undercounted for exactly that reason.
So before anyone reads a percentage off a dashboard, check three things:
- Does your commit tooling preserve trailers verbatim?
- Do squash merges keep the trailers from the squashed commits? GitHub's squash-merge preserves
Co-Authored-Byin the generated message; many custom merge bots do not. - Does a rebase-and-fixup workflow drop them?
git rebasepreserves the message body, but--autosquashwith a rewritten message will not.
If the answer to any of those is no, your number is a floor under a floor, and the right move is to fix the convention before you fix the metric.
What to do with it
Once the attribution is trustworthy, store it as an asset rather than a report. A percentage in a PDF ages out in a week; a record attached to the repository, refreshed each scan, with the model breakdown and the scan date attached, answers the auditor's actual question — which of your code was machine-generated, by what, and when did you last check — without anyone re-running an investigation.
And store the basis alongside the number. A field that reads commit trailers; per-commit, not per-line; a floor rather than an estimate costs one string and prevents the entire class of misreading that makes provenance metrics untrustworthy in the first place.