Adding trailers automatically with a commit-msg hook Jump to heading

Trailers are the part of a commit message that tools are allowed to read. Git treats the final block of Key: value lines as structured data, exposes it through git log --format='%(trailers)', and preserves it through cherry-picks. Everything else in the message is prose, and parsing prose is how changelog generators end up wrong. This recipe adds the trailers you care about automatically, so the structure exists without anyone having to remember it β€” part of commit message hooks and templates.

When to use this approach Jump to heading

  • Release notes, changelogs or audit reports are generated from commit history.
  • Pair and mob programming is common and co-authorship needs to be attributed.
  • You need a reliable link from a commit back to its ticket or review.
  • Contributions require a sign-off line for licensing reasons.
  • If nothing downstream reads trailers, adding them is ceremony; decide the consumer first.

Step 1 β€” Use git interpret-trailers rather than appending text Jump to heading

Appending a line with echo looks equivalent and is not. git interpret-trailers knows where the trailer block starts, how to avoid duplicating an existing key, and how to keep the body separated correctly.

# Wrong: blind append β€” breaks when the body already ends with a trailer block
echo "Refs: PAY-812" >> "$msg_file"

# Right: Git decides where the line belongs
git interpret-trailers --in-place --if-exists doNothing \
  --trailer "Refs: PAY-812" "$msg_file"
# Verification: read the trailer back the way tooling will
git log -1 --format='%(trailers:key=Refs,valueonly)'

The --if-exists doNothing flag is what makes the hook safe to run repeatedly, which matters during amends and rebases where the same hook fires again over a message that already has the trailer.

The three parts of a commit messageGit treats the first line as the subject, everything up to the final block as the body, and a trailing run of Key: value lines as trailers. Only the last part is structured, and only it can be read back reliably.what a commit message is made ofSubjectfirst line, read in every logBodyprose for humans, unparsedTrailer blockKey: value β€” machine readableanything a tool must read later belongs in the bottom block

Step 2 β€” Add co-authors from a pairing session Jump to heading

Pair programming loses the second author unless something records it. A small helper turns a shorthand into a proper trailer.

#!/usr/bin/env sh
# .husky/commit-msg β€” expand "+ada" style mentions into Co-authored-by trailers.
set -eu
msg_file="$1"

# A team roster committed alongside the hook: handle<TAB>Name <email>
roster=.github/pairs.tsv
[ -f "$roster" ] || exit 0

grep -oE '(^|[[:space:]])\+[a-z0-9_-]+' "$msg_file" | tr -d ' +' | sort -u |
while read -r handle; do
  line=$(awk -F'\t' -v h="$handle" '$1==h {print $2}' "$roster")
  [ -n "$line" ] || continue
  git interpret-trailers --in-place --if-exists addIfDifferent \
    --trailer "Co-authored-by: $line" "$msg_file"
done

# Remove the shorthand now that it has been expanded.
sed -i.bak -E 's/(^|[[:space:]])\+[a-z0-9_-]+//g' "$msg_file" && rm -f "$msg_file.bak"
# Verification: the shorthand disappears and a proper trailer appears
printf 'fix(pay): widen refund window +ada\n' > /tmp/m
sh .husky/commit-msg /tmp/m && cat /tmp/m

What changed: authorship survives a squash merge, because forges read Co-authored-by trailers from the composed message and attribute the commit to both people.

Step 3 β€” Keep sign-off honest Jump to heading

git commit -s adds a Signed-off-by line. Generating that line for someone else in a hook defeats its purpose, since the whole point is a deliberate assertion by the author.

# Configure the convenience, not an automatic assertion
git config format.signOff true            # adds -s to format-patch
git config alias.cs 'commit -s'           # explicit, still a choice

# Verify who signed off on the range being proposed
git log --format='%h %s%n  %(trailers:key=Signed-off-by,valueonly)' origin/main..HEAD

SAFETY WARNING β€” never add Signed-off-by from a hook. It is a legal statement about the person named, and generating it automatically means the repository records an assertion nobody made. Prompt for it, reject a commit that lacks it if your project requires one, but do not write it on someone’s behalf.

Step 4 β€” Read trailers back without parsing prose Jump to heading

Once the data is structured, every consumer should use the structured accessor. This is the difference between a changelog that is correct and one that is correct until someone writes β€œrefs” in a sentence.

# Every ticket referenced since the last tag
git log "$(git describe --tags --abbrev=0)"..HEAD \
  --format='%(trailers:key=Refs,valueonly)' | sort -u

# Commits missing a required trailer, as a gate
git log origin/main..HEAD --format='%H %(trailers:key=Refs,valueonly)' \
  | awk 'NF==1 {print "missing Refs: " $1; bad=1} END {exit bad+0}'
Reading a trailer against grepping the bodyThe structured accessor returns only real trailers from the trailer block, ignores the same words in prose, and survives cherry-picks. A grep over the body matches quoted text, comments and coincidences, and the errors it produces are silent.grep the body%(trailers:key=…)matches prose mentionsyes β€” false positivesnosurvives cherry-pickdepends on the textyescase and spacingfragilecanonicalisedeffortone pipeone format stringthe accessor exists precisely so release tooling does not have to guess

Step 5 β€” Canonicalise the keys you define Jump to heading

Teams invent trailers, then spell them three ways. Git can normalise a token to a canonical key, which makes downstream reads reliable.

# Accept "refs" and "ref" but store "Refs"
git config trailer.refs.key "Refs: "
git config trailer.refs.ifExists addIfDifferent

# Verification: the alias is rewritten to the canonical spelling
printf 'subject\n\nrefs: PAY-1\n' | git interpret-trailers

Commit these settings through the shared configuration described in shipping a team gitconfig with includeIf, or half the team writes the canonical form and half does not.

From a typed shorthand to data a release job can readThe author types a shorthand. The hook expands it through a roster into a canonical trailer, Git stores it in the trailer block, and the release job reads it with a format accessor rather than a regular expression.Shorthand+ada in the messageHook expandsroster lookupinterpret-trailersCanonical trailerCo-authored-by:Release job%(trailers:key=…)no parsingeach arrow removes a way the data could have been wrong

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Do trailers survive rebases and cherry-picks? Jump to heading

Yes β€” they are part of the message, and both operations copy the message. That is the main practical advantage over storing metadata anywhere else: the association follows the commit wherever it is replayed, including into a release branch during a backport.

What happens to trailers when a pull request is squashed? Jump to heading

The forge composes a new message, usually from the title and body, and most implementations collect Co-authored-by trailers from the squashed commits. Other trailers are typically dropped, so if you depend on one, put it in the pull request body as well.

Can I require a trailer without annoying everyone? Jump to heading

Require it where it is cheap to satisfy and derive it wherever possible. A Refs trailer taken from the branch name costs the author nothing; a free-text trailer that must be typed on every commit will be filled with placeholder values within a fortnight.