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.
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-byfrom 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}' 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.
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.
Related Jump to heading
- Commit Message Hooks & Templates β the parent topic and the hook ordering.
- Enforcing Issue Keys in Commit Messages β the rule that a
Refstrailer satisfies. - Automating Changelog Generation With semantic-release β the consumer that makes structured messages worth the effort.