Generating a commit message template per branch Jump to heading

A single commit.template is better than nothing and worse than it could be. The prompts a feature branch needs β€” what changed, why now, which ticket β€” are not the prompts a hotfix needs, which are closer to what broke, what the blast radius was, and which release the fix must reach. Because prepare-commit-msg runs with the branch already checked out, choosing the right template is a few lines of shell. This recipe wires that up, within commit message hooks and templates.

When to use this approach Jump to heading

  • Your branches already follow a naming convention such as feat/, fix/ or release/.
  • Different kinds of change genuinely need different information recorded.
  • Messages are read later by release notes, incident reviews or an audit.
  • Authors currently stare at an empty editor and write one line to escape it.
  • If every change on your repository is the same shape, one static template is simpler and enough.

Step 1 β€” Write one template per branch kind Jump to heading

Keep them in the repository so they are reviewed like code and travel with a clone.

mkdir -p .git-templates
cat > .git-templates/feature.txt <<'TPL'
# <type>(<scope>): <subject>        keep under 72 characters, imperative mood
#
# Why is this change needed?
#
# What does a reviewer need to know that the diff does not show?
#
# Refs:
TPL
cat > .git-templates/hotfix.txt <<'TPL'
# fix(<scope>): <subject>
#
# Symptom observed in production:
#
# Root cause:
#
# Blast radius / who was affected:
#
# Which release branches must this reach?
#
# Refs:
TPL
# Verification: the files exist and are committed, not sitting untracked
git add .git-templates && git status --short .git-templates
Three branch kinds, three sets of questionsA feature branch needs the reasoning behind a deliberate change. A hotfix needs the symptom, the cause and which release branches it must reach. A release branch needs the version and the scope of what is included.feat/why nowreviewer contextticket referencefix/symptomroot causewhich releasesrelease/versionscope includedsign-offthe prompts differ because what will be needed six months later differs

Step 2 β€” Select the template from the branch name Jump to heading

#!/usr/bin/env sh
# .husky/prepare-commit-msg β€” pick a template, then fill in what we already know.
set -eu
msg_file="$1"; source="${2:-}"

# Leave generated and -m messages alone entirely.
case "$source" in merge|squash|commit) exit 0 ;; esac
# Only act on an empty message β€” never overwrite what someone has written.
grep -qv '^#' "$msg_file" 2>/dev/null && [ -s "$msg_file" ] && \
  grep -q '[^[:space:]#]' "$msg_file" && exit 0

branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo '')
case "$branch" in
  fix/*|hotfix/*) tpl=.git-templates/hotfix.txt ;;
  release/*)      tpl=.git-templates/release.txt ;;
  *)              tpl=.git-templates/feature.txt ;;
esac
[ -f "$tpl" ] || exit 0

cat "$tpl" "$msg_file" > "$msg_file.new" && mv "$msg_file.new" "$msg_file"
# Verification: check which template each branch kind resolves to
for b in feat/PAY-1 fix/PAY-2 release/2.4.0; do
  printf '%s -> ' "$b"
  case "$b" in fix/*|hotfix/*) echo hotfix ;; release/*) echo release ;; *) echo feature ;; esac
done

The guard on an already-populated message is the important part. A git commit -m, an amend, or a rebase continuation must not have a template pasted into it.

Step 3 β€” Pre-fill the derivable fields Jump to heading

Everything the branch name already states should arrive filled in. The author’s attention is the scarce resource; spend it on the parts only they can supply.

# Appended inside the same hook, after the template is in place
key=$(printf '%s' "$branch" | grep -oE '[A-Z]{2,10}-[0-9]+' | head -n1 || true)
[ -n "$key" ] && printf 'Refs: %s\n' "$key" >> "$msg_file"

# On a release branch, the version is in the name
case "$branch" in
  release/*) printf 'Release: %s\n' "${branch#release/}" >> "$msg_file" ;;
esac
# Verification on a scratch branch, with commit.verbose on for good measure
git config commit.verbose true
git checkout -b fix/PAY-931-timeout && git commit --allow-empty
What the hook fills and what the author writesThe branch name supplies the change kind, the tracker key and, on a release branch, the version. The template supplies the prompts. The author supplies the reasoning, which is the only part no automation can produce.Branch namefix/PAY-931-timeoutTemplate chosenhotfix promptsFields derivedRefs: PAY-931kind: fixAuthor writessymptom, causeblast radiusthree of the four boxes cost the author nothing

Step 4 β€” Keep the comment markers from leaking into history Jump to heading

Template lines begin with # so Git strips them β€” but only under the default cleanup mode. A hook that appends diagnostics after the message, or a team that switches cleanup modes, can leave the prompts in the commit.

# Confirm cleanup is stripping comments as expected
git config --get commit.cleanup || echo "default (strips # lines)"

# After a test commit, the body must not contain the prompts
git commit --allow-empty -m "$(cat .git-templates/feature.txt)" 2>/dev/null
git log -1 --format='%b' | grep -c '^#' || echo "clean: no comment lines in the body"

If you need to keep # lines in a message deliberately, switch to commit.cleanup=scissors, which strips only below a scissors line and leaves everything above untouched.

When the hook must keep its hands off the messageMerges, squashes and messages supplied with -m already contain text, and rebases replay messages that were written earlier. Pasting a template into any of them destroys information, so the hook exits before touching the file.Is the message file already populated?yes β€” merge or -mExit immediatelynever overwriteno β€” empty editorInsert the templatethen derive fieldsone guard prevents the whole class of 'my message got mangled' reports

Step 5 β€” Roll it out without a manual setup step Jump to heading

A template is worthless if half the team never applies it. Install the hook path through the repository’s hook manager, so a fresh clone picks it up during dependency installation rather than during an onboarding conversation.

# With Husky, the hooks directory is set by the install step
git config --get core.hooksPath      # expect .husky or similar

# A one-line drift check that belongs in CI
test "$(git config --get core.hooksPath)" = ".husky" \
  || echo "hooks are not installed in this clone"

The wider version of this problem β€” making every clone agree on configuration β€” is covered in bootstrapping a developer machine for Git.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not use a global template in the home directory? Jump to heading

Because it is invisible to everyone else and cannot be reviewed. A template committed to the repository changes through a pull request like any other rule, and a new contributor gets it on their first clone instead of on their first correction.

Does this interfere with commitizen or a prompt-based tool? Jump to heading

They overlap, so pick one. A guided prompt replaces the editor entirely and is the better fit when the convention is strict; templates are lighter and keep the editor workflow. Running both means the template’s prompts appear inside an answer the tool already composed.

What happens on a detached HEAD? Jump to heading

git symbolic-ref fails, the branch string is empty, and the default template applies. That is the right behaviour during a rebase or a bisect, where imposing a hotfix template on a replayed commit would be noise.