Skip to main content

How to stop Claude Code adding Co-Authored-By to your commits

·5 mins

TL;DR: CLAUDE.md loses to a later system-level instruction, so telling Claude Code not to add Co-Authored-By does not 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-By lines or any Claude/AI attribution in commit messages.

It kept happening anyway. I would notice 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 Does Not 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).”

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. 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": ""
  }
}

Empty string means “no attribution text”. commit covers the Co-Authored-By trailer; pr covers the 🤖 Generated with [Claude Code] line in pull request descriptions.

You can verify it took effect without committing anything. Before the change, the injected reminder 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 is the whole fix, and if you only do one thing, do this one.

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 skill that hardcodes it, a pasted template, a habit.

Hooks are the only way to get automatic enforcement. A preference in CLAUDE.md is advisory; a hook is a gate the harness runs, and PreToolUse can deny the tool call outright.

The logic, 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."}}'

Hooks receive the tool call as JSON on stdin and control 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.

Wired into settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/no-ai-attribution.sh",
            "statusMessage": "Checking commit attribution"
          }
        ]
      }
    ]
  }
}

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 disappear.

Test a Hook Without Committing Anything #

You do not 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 <[email protected]>"' '{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 test fixtures failed to parse and I briefly thought the hook was broken when it was the test that was wrong.

Worth covering explicitly:

CaseExpected
git commit -m with the trailerdeny
git commit -F - with a heredoc bodydeny
gh pr create --body with the bannerdeny
A clean commitallow
A grep that merely mentions the stringallow
Any unrelated commandallow

The heredoc case matters more than it looks. 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, and a fair one for a backstop whose primary defence is the setting above.

Do Not Skip the Setting #

It is 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.

Mateusz Soltysik
Author
Mateusz Soltysik
Senior Software Engineer & Tech Lead | Python • Go • TS | Certified AWS & GCP Architect