Commit Message Hooks & Templates Jump to heading
A commit message is the only part of a change that survives every tool. Diffs get squashed, branches get deleted, review threads live in a system you may not be using in five years — the message stays in the object database for as long as the repository exists. That makes it the cheapest place to record why a change was made, and the most expensive place to discover, years later, that nobody did. This part of Git Automation & CI/CD Hook Engineering covers the automation that makes good messages the default: the hooks that check them, the templates that pre-fill them, and the trailers that let machines read them.
Prerequisites Jump to heading
Where Message Automation Runs Jump to heading
Three hooks touch a commit message and they fire in a fixed order. prepare-commit-msg runs before the editor opens and is where a template or a derived value belongs. commit-msg runs after the editor closes and is the only one that can reject. post-commit runs afterwards and can only report. Putting a check in the wrong one produces behaviour that feels arbitrary: a rule that silently rewrites what someone typed, or a validation that fires before there is anything to validate.
Step 1 — Write a commit-msg Hook That Fails Clearly Jump to heading
The hook receives one argument: the path to a file containing the message. Read it, decide, and exit non-zero to reject. The part that decides whether the rule survives contact with the team is the error message.
#!/usr/bin/env sh
# .husky/commit-msg — reject messages that cannot be read six months from now.
set -eu
msg_file="$1"
subject=$(head -n1 "$msg_file")
# Comment lines and an empty message mean the commit was aborted; let git handle it.
[ -z "$subject" ] && exit 0
case "$subject" in '#'*) exit 0 ;; esac
if [ "${#subject}" -gt 72 ]; then
echo "commit-msg: subject is ${#subject} characters; keep it under 72." >&2
echo " $subject" >&2
echo " Detail belongs in the body, after a blank line." >&2
exit 1
fi # Verification: both paths, without making a real commit
printf 'a short subject\n' > /tmp/m && sh .husky/commit-msg /tmp/m && echo "accepted"
printf '%0.sx' $(seq 1 90) > /tmp/m && sh .husky/commit-msg /tmp/m || echo "rejected as expected" A rejection that prints the offending line and the reason costs three extra lines of shell and removes the single most common complaint about message hooks, which is that they say no without saying why.
Step 2 — Pre-fill the Message With a Template Jump to heading
Most message conventions are easier to follow than to remember. A template turns the convention into the thing that is already on screen when the editor opens.
# .gitmessage — committed to the repository, not to a developer's home directory
#
# <type>(<scope>): <subject> # under 72 characters, imperative mood
#
# Why this change is needed:
#
# Refs: # Point the repository at it, and make setup part of onboarding
git config commit.template .gitmessage
git config --get commit.template # verification Because commit.template is a repository-local setting, it has to be applied on every clone. Shipping it through the team configuration described in shipping a team gitconfig with includeIf is more reliable than asking people to run a command once.
Step 3 — Derive What the Machine Already Knows Jump to heading
If the branch is named feat/PAY-812-refund-window, nobody should be typing PAY-812 by hand. A prepare-commit-msg hook can insert it, leaving the human to write the part only a human knows.
#!/usr/bin/env sh
# .husky/prepare-commit-msg — add the issue key from the branch name.
set -eu
msg_file="$1"; source="${2:-}"
# Never touch merges, squashes, amends or messages supplied with -m.
case "$source" in merge|squash|commit) exit 0 ;; esac
branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo '')
key=$(printf '%s' "$branch" | grep -oE '[A-Z]{2,10}-[0-9]+' | head -n1 || true)
[ -n "$key" ] || exit 0
grep -q "$key" "$msg_file" && exit 0 # already present; leave it alone
printf '\nRefs: %s\n' "$key" >> "$msg_file" # Verification: on a branch named like the example, the key appears in the editor
git checkout -b feat/PAY-812-refund-window && git commit --allow-empty Step 4 — Use Trailers So Tools Can Read the Message Jump to heading
Free prose is for people; trailers are for machines. Git has first-class support for them through git interpret-trailers, and using it beats inventing a parser.
# Append a trailer without disturbing the body
git interpret-trailers --in-place --trailer 'Reviewed-by: Ada Lovelace <[email protected]>' /tmp/msg
# Read trailers back out of history — this is what release tooling should use
git log -1 --format='%(trailers:key=Refs,valueonly)' Trailers are how automating changelog generation with semantic-release gets structured data out of history, and how co-authorship survives a squash.
Step 5 — Decide Where the Rule Is Enforced Jump to heading
A local hook is a convenience for the person who has it installed. It is not enforcement: it does not run on a fork, it does not run for a web-interface edit, and it is one --no-verify away from not running at all. Anything you actually depend on has to be re-checked where you control the machine.
The pipeline version is the practical default for open repositories, and the mechanics are in linting commit messages for forked pull requests. Where you run your own Git server, mirroring local hook checks in server-side policy covers the stricter option.
Configuration Reference Jump to heading
| Setting or hook | Default | Effect | When to change |
|---|---|---|---|
commit.template | unset | Pre-fills the editor from a file | Set per repository, ship via team config |
commit.cleanup | default | Strips comments and trailing blank lines | scissors when a hook appends diagnostics |
commit.verbose | false | Shows the diff in the editor | Enable — it measurably improves message quality |
prepare-commit-msg | absent | Fills the message before the editor | Use for derived values only, never judgements |
commit-msg | absent | The only hook that may reject | Keep it fast and make errors explicit |
trailer.<token>.key | unset | Canonical spelling for a trailer | Set when tools read a trailer you define |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook rejects merge commits | The hook does not inspect its second argument | Exit early when the source is merge or squash |
| Template ignored on a fresh clone | commit.template is repository-local and unset | Apply it during onboarding or via team config |
| Issue key duplicated in the message | The hook appends without checking | grep for the key before writing |
| Rule passes locally, fails in CI | Different rule versions on the two sides | Share one configuration file between them |
--no-verify used routinely | The hook is slow or its errors are unclear | Measure it; keep it under a second and explain rejections |
| Trailers lost after a squash | Squash concatenates messages without reinterpreting them | Re-run interpret-trailers when composing the squash message |
Rolling a Convention Out Without a Revolt Jump to heading
Message rules have a reputation problem, and it is earned. The typical rollout announces a convention, switches on a hook that rejects everything not matching it, and leaves the team to discover the rules by being refused. Within a week the rejections are being routed around, and the convention exists only in a document nobody reads. A rollout that works inverts the order: make the desired behaviour easy first, and only then make the undesired behaviour impossible.
The measurement step is the one most often skipped and the one that most often changes the design. A rule requiring a tracker key looks obviously reasonable until the numbers show that a fifth of commits are release chores, dependency bumps and reverts with no ticket — at which point the rule either grows an exemption list or trains everyone to write NO-TICKET in a field that then carries no information.
There is a second reason to move slowly, which is that a message convention encodes assumptions about how work is organised, and those assumptions age. A scope list that matched the codebase two years ago now rejects the name of the service everyone works on; a type list written for a library does not fit an application. Treat the configuration as something that changes through a pull request like any other code, with the same review, and the convention keeps pace with the project instead of becoming an obstacle people have learned to step around.
Frequently Asked Questions Jump to heading
Should a hook rewrite a message it does not like? Jump to heading
No. Rewriting silently changes what somebody wrote, which is exactly the surprise that turns people against hooks. Derive missing mechanical facts in prepare-commit-msg before the author sees the message, and in commit-msg reject with an explanation rather than fixing it for them.
Do commit message rules survive a squash merge? Jump to heading
Only the composed message survives, and most forges compose it from the pull request title and body rather than from the commits. If you rely on trailers or issue keys, validate the title and body too, or the rule applies to messages that are discarded at merge time.
Is a strict convention worth the friction? Jump to heading
A convention earns its keep when something reads it — release notes, an issue tracker link, a changelog, a search through five years of history. Enforcing a format that nothing consumes is pure friction, so pick the convention after deciding which tool will read it.
What about messages written in the web interface? Jump to heading
They bypass every local hook, because there is no local repository. This is the clearest argument for enforcing in the pipeline: it is the only layer that sees every commit regardless of where it was authored.
Related Jump to heading
- Enforcing Issue Keys in Commit Messages — the full rule, including the exemptions that keep it usable.
- Generating a Commit Message Template Per Branch — different templates for features, fixes and releases.
- Adding Trailers Automatically With a commit-msg Hook — co-authors and review metadata that machines can read.
- Wiring Commitizen Into an Existing Repo — a guided prompt instead of a rejection.
- How to Enforce Conventional Commits With Commitlint — the convention most of these rules end up encoding.