Who wrote this commit? Git attribution when an agent does the work
8m read time

Who wrote this commit? Git attribution when an agent does the work

I went looking for the coding agent in 1,644 commits across ten of my public repositories. It left no trace at all. The thing that had quietly rewritten the authorship record was the merge button.

I went looking for the agent in my own git history.

Ten public repositories, 1,644 commits on main on the afternoon I counted them. Some of that work involved a coding agent and some of it did not, and I want to be exact about the next part: I cannot tell you which. Nothing in the history separates them. Not one attribution trailer, on any branch, in any of the ten.

That is the first finding, and it is worth sitting with before the rest. The record I would reach for to answer "did a machine help with this one" does not contain the answer, and I am the person who wrote the repositories.

The second finding is that the record had been rewritten anyway, by something nobody argues about.

The trailer everyone is arguing about

Claude Code ships a commit trailer. By default it appends Co-Authored-By: Claude <noreply@anthropic.com> to commits and a "Generated with Claude Code" line to pull request bodies. This has been contentious enough to produce bug reports, one of which argues the default is simply inaccurate.

That report has a point worth reading properly. The US Copyright Office's registration guidance says, verbatim: "Applicants should not list an AI technology or the company that provided it as an author or co-author simply because they used it when creating their work." The same guidance holds that "to qualify as a work of 'authorship' a work must be created by a human being". Git tooling treats every Co-authored-by name as an author of the commit, so the trailer makes a claim about authorship.

The setting that controls it has moved. includeCoAuthoredBy is deprecated in favour of an attribution object that handles the commit trailer and the pull request body separately. Both are still read. In version 2.1.263 the resolution runs in three steps: use attribution if it is set and valid, otherwise emit nothing if includeCoAuthoredBy is exactly false, otherwise emit the trailer.

Two things follow from that order. One is documented somewhere you will never look, and one is not documented at all.

The first is a genuine footgun: the attribution branch fills each field with ??, so a missing key falls back to the built-in default rather than to empty. Set attribution: {"pr": ""} because the pull request footer annoyed you, and you have silently kept the commit trailer you thought you were configuring away. An empty string suppresses. An absent key restores.

Anthropic does state this, in the settings schema that ships inside the same binary: "Each field defaults to the standard Claude Code attribution if not set", and "Empty string hides attribution." It is exact, it is correct, and it lives in a JSON schema rather than in the page you read when you went looking for how to turn the trailer off.

The second is a small piece of archaeology: taking the deprecated path fires a telemetry counter named tengu_dead_probe_include_coauthored_by, guarded so it reports once per site. A "dead probe" is what you instrument when you want to know whether anyone still uses a code path before you delete it. Setting the modern key means the probe never fires, because the first branch returns before it. So the population being counted is precisely the people who set only the old key, and set it to false.

All of which took one line in my settings to resolve. attribution with empty strings, globally and in the project. That is why the trailer count across ten repositories is zero, and why the history cannot answer the question I opened with.

I am not sure that is wrong. A trailer that marks a commit as machine-assisted sounds like transparency until you ask what a reader would do with it: skip the review, or review it harder? The code is either defensible or it is not, and the trailer does not tell you which. What I am sure of is that turning it off was my decision, and it is why the history of that work will stay silent on the subject.

I had assumed that was the end of the story.

Then I counted the authors

Bar chart of who authored 1,644 commits: git config 1289, GitHub 355, the agent 0.

Ten distinct author identities across 1,644 commits, for a history written by one person and two bots.

sharewho
29.0%my GitHub noreply address, which is the one every repository is configured to use
27.8%my name against a different address entirely
22.3%a work address from a former arrangement
14.0%five more spellings of me, in various combinations of name and address
6.8%github-actions[bot]
0.1%a review bot

Every one of those ten repositories has user.name and user.email pinned locally to a single identity. I check it. There is a rule about it. And it accounts for well under a third of the history.

The reason is dull and it is the whole point: gh pr merge --squash merges on the server. Your local git config is never consulted, because your machine is not the thing making the commit. GitHub builds it, and attributes it to your account profile and its commit email setting. Merge forty-five pull requests in a day, as I did the day I measured this, and those commits land under whichever identity your account happens to advertise, regardless of what every clone on your disk says.

Across the whole history, 355 commits were made by GitHub's servers rather than by any machine of mine.

The same merge writes the Co-authored-by trailers. All 161 of them in my history are GitHub's, added for the authors of the commits being squashed. GitHub documents the trailer itself, and documents that a co-author's address should be one attached to their account. It does not document that the merge button will write these for you.

So the record contains, on the same line, an author I did not choose and co-authors I did not add.

What the record is for

This matters because of what people want git log to do. The argument that AI-generated code needs an audit trail usually lands on the commit, because the commit is the thing that persists. Who is responsible for AI-generated code is a question people expect the history to help answer.

It cannot, and the agent is not why. Before a single line about AI enters the discussion, the commit's own author field is set by whoever pressed a button in a web interface, and the trailers underneath it are generated by a merge strategy. A trailer naming the model would sit inside a record that is already three parties' work: your git config for local commits, GitHub's account settings for merged ones, and the merge strategy for everything below the subject line.

Add a model's name to that and you have not created accountability. You have added a fourth writer to a document nobody was reading carefully.

The honest version is that the commit is a poor place for this. The pull request is where the reasoning lives: what was asked for, what the review caught, what the checks said, who approved it. That record is richer, it is harder to rewrite by accident, and it survives a squash. The commit that lands is a summary of it, written by a machine, attributed by a web server.

What I would actually do

Decide what you want the history to answer, then configure for that answer.

  • Set attribution explicitly, with every key you care about present. Empty strings suppress. Omitted keys restore the default, quietly.
  • Set your account's commit email as well as the one in your local config. The local one governs commits you make. The account one governs every commit GitHub makes on your behalf, which on a squash-merge workflow is most of them.
  • Check what your history actually says before trusting it. git log --format='%an <%ae>' | sort | uniq -c | sort -rn takes a second and told me something I would have sworn was not true.
  • Put the accountability in the pull request, where it has room to be specific, and let the commit be a summary.

The thing I keep coming back to is how little the agent had to do with any of it. I went looking for its fingerprints and found a clean absence, because of one line I had put in my own settings. Everything actually wrong with the record was made by a merge strategy I chose years ago and a profile setting I have never once looked at.

We have spent the better part of two years arguing about whether a model belongs in the author field. Meanwhile the author field was being filled in by a web server.

(4 of 4)