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.

The three hooks that see a commit messageprepare-commit-msg populates the message file before the editor opens, so it is where templates and derived values belong. commit-msg runs after the editor closes and is the only hook that can reject the commit. post-commit runs once the object exists and can only report.prepare-commit-msgfill the templatederive from brancheditorthe human typescommit-msgvalidate or rejectexit non-zeropost-commitreport onlyobject already existsonly the third box can say no — the first one must never silently rewrite intent

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
Typed by a person against derived from the branchAn issue key typed by hand is wrong often enough to matter and is the part of the message that carries no judgement. Deriving it from the branch name removes the error class entirely while leaving the explanatory text to the author.Typed by handDerived from the branchtyposfrequentimpossibleforgotten entirelycommonnevereffort per commita few secondsnoneworks with -myesleft untouchedautomate the part with no judgement in it, and only that part

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 same rule at three enforcement pointsA local hook gives fast feedback to the person who installed it. A pipeline check covers every contributor including forks, at the cost of a round trip. A server-side hook cannot be bypassed at all but is invisible until a push is rejected.Local hookinstant feedbackskippablenot on forksPipeline checkcovers every authorone round tripclear reportServer-side hookcannot be bypassedrejects at pushterse errorsuse the first for speed and one of the others for truth — never the first alone

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 hookDefaultEffectWhen to change
commit.templateunsetPre-fills the editor from a fileSet per repository, ship via team config
commit.cleanupdefaultStrips comments and trailing blank linesscissors when a hook appends diagnostics
commit.verbosefalseShows the diff in the editorEnable — it measurably improves message quality
prepare-commit-msgabsentFills the message before the editorUse for derived values only, never judgements
commit-msgabsentThe only hook that may rejectKeep it fast and make errors explicit
trailer.<token>.keyunsetCanonical spelling for a trailerSet when tools read a trailer you define

Troubleshooting Jump to heading

SymptomLikely causeFix
Hook rejects merge commitsThe hook does not inspect its second argumentExit early when the source is merge or squash
Template ignored on a fresh clonecommit.template is repository-local and unsetApply it during onboarding or via team config
Issue key duplicated in the messageThe hook appends without checkinggrep for the key before writing
Rule passes locally, fails in CIDifferent rule versions on the two sidesShare one configuration file between them
--no-verify used routinelyThe hook is slow or its errors are unclearMeasure it; keep it under a second and explain rejections
Trailers lost after a squashSquash concatenates messages without reinterpreting themRe-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.