How to stop Claude Code adding Co-Authored-By to your commits
Table of Contents
TL;DR: CLAUDE.md loses to a later system-level instruction, so telling Claude Code not to add Co-Authored-By doesn’t stick. One setting stops the attribution being injected at all; a PreToolUse hook is the backstop - and my first version of it denied my own grep.
My global CLAUDE.md has said this for months:
Never include
Co-Authored-Bylines or any Claude/AI attribution in commit messages.
It kept happening anyway. I would notice it eight commits into a branch, rewrite history, force-push, and move on. Then it would happen again.
The instruction was not being ignored. It was being overridden.
Why CLAUDE.md Alone Doesn’t Win #
Claude Code injects its own attribution directive into the session, and that directive explicitly claims precedence over earlier guidance. Mine arrived worded roughly as “end git commit messages with Co-Authored-By: ... (this replaces any earlier attribution guidance).” The exact wording is version-specific, so don’t pattern-match on it - the point is that it arrives at all, and that it claims precedence.
Faced with a standing rule in CLAUDE.md and a later system-level instruction that says it supersedes earlier ones, the model follows the later one. That is defensible behaviour in the abstract and exactly wrong here.
The directive is injected because of a setting, not because of a prompt - which is why changing a setting changes it, as the next section shows. So the fix belongs in settings, not in more prose telling the model to behave.
The Actual Fix: One Setting #
In ~/.claude/settings.json:
{
"attribution": {
"commit": "",
"pr": ""
}
}
An empty string means “no attribution text”. commit covers the Co-Authored-By trailer; pr covers the “Generated with Claude Code” banner that otherwise lands in pull request descriptions.
You can verify it took effect without committing anything: start a fresh session and ask Claude what its current instruction about commit attribution is. Before the change it told me to add the trailer. After it, the same reminder read:
do not add attribution lines to git commit messages or pull request descriptions
Same mechanism, opposite instruction. That’s the whole fix.
The Backstop: A PreToolUse Hook #
The setting handles the default path. A hook catches the case where the trailer gets reintroduced some other way - a Claude Code skill that hardcodes it, a pasted template, a habit.
A preference in CLAUDE.md is advisory. A hook is a gate Claude Code itself runs, before the tool call happens and outside the model’s control, and PreToolUse can deny the call outright.
Claude Code pipes the pending tool call to the hook as JSON on stdin, so .tool_input.command is the shell command it is about to run. Saved as ~/.claude/hooks/no-ai-attribution.sh:
cmd=$(jq -r '.tool_input.command // ""')
# Only commands that actually write a commit, tag, PR, or release can carry attribution.
printf '%s' "$cmd" | grep -qE 'git +(commit|tag)|gh +pr +(create|edit)|gh +release +create' || exit 0
printf '%s' "$cmd" | grep -qiE 'co-authored-by:|generated with \[?claude' || exit 0
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"CLAUDE.md forbids Claude/AI attribution. Remove the line and retry."}}'
Your hook controls the outcome with JSON on stdout. Printing nothing means “no opinion, carry on”. Printing a permissionDecision of deny blocks the call and shows the model your reason, so it can correct itself rather than just failing.
This needs jq on your PATH. Without it cmd comes back empty, both greps miss, and the script exits clean - it fails open, allowing everything, while looking exactly like a hook that is working. Worth knowing before you trust it.
Wired into settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/no-ai-attribution.sh",
"statusMessage": "Checking commit attribution"
}
]
}
]
}
}
Both JSON blocks belong to the same ~/.claude/settings.json - merge the keys into one top-level object, do not paste the second over the first. Dropping attribution on the way past would leave you with only the backstop and none of the fix. Settings are read at session start, so restart Claude Code afterwards.
matcher scopes the hook to Bash tool calls; other matchers exist for other tools. statusMessage is cosmetic - it is what shows in the UI while the hook runs.
The Gotcha: My First Version Blocked My Own grep #
My first attempt skipped the command-shape check and went straight to the attribution match:
# BAD - matches the string anywhere in any command
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -qiE 'co-authored-by|generated with \[?claude'; then
# deny
fi
It worked. It blocked a real commit carrying a trailer, exactly as intended.
Then I ran a read-only sanity check to confirm the branch was clean:
git log 90dcef1..HEAD --format=%B | grep -ci "co-authored-by"
Denied. The hook matched the string in my own grep pattern.
This is the trap with content-matching hooks: the hook sees the command text, not the command’s effect. Any command that so much as mentions the forbidden string gets caught, including the diagnostics you write to check whether the hook is working. Gate on the command shape first, then on the content. Two cheap greps, and the false positives all but disappear - a commit message that talks about the trailer will still trip it, which I can live with.
Test a Hook Without Committing Anything #
You don’t need to make a real commit to test this. Synthesise the stdin payload and pipe it straight in:
jq -nc --arg c 'git commit -m "x
Co-Authored-By: Claude <n@a.com>"' '{tool_input:{command:$c}}' | bash ~/.claude/hooks/no-ai-attribution.sh
Build the payload with jq --arg rather than hand-writing the JSON. Commit messages are multi-line, and a raw newline inside a JSON string is invalid - my first fixtures failed to parse and I briefly thought the hook was broken when it was the test that was wrong.
The cases worth pinning down:
| Case | Expected |
|---|---|
git commit -m with the trailer | deny |
git commit -F - with a heredoc body | deny |
gh pr create --body with the banner | deny |
| A clean commit | allow |
A grep that merely mentions the string | allow |
| Any unrelated command | allow |
The heredoc case matters more than it might seem. Agents often write commit messages as git commit -F - <<'MSG' ... MSG, and the heredoc body is part of the command string, so a content match still catches it. A message passed via a separate file (git commit -F msg.txt) would slip through - a real limit of this approach. If you want that path covered too, a git commit-msg hook sees the final message however the commit was invoked; it is the belt to this approach’s braces, and it is the one form of enforcement that does not care which tool wrote the message.
Don’t Skip the Setting #
It’s tempting to write only the hook, because a hook feels like real enforcement. Resist that.
The hook denies the tool call after the model has composed a commit message it believes is correct, which costs a round trip every time. The setting stops the instruction being injected at all, so the message is right the first time. The hook exists for the paths the setting does not cover.
Fix the cause, then guard the edge.