A .gitmessage Template and Commit Hooks That Enforce It
· Jerwin Arnado ·
Conventional Commits only pays off if every commit follows it. In practice, half a team adopts the format, half forgets, and six months later your history is back to “fix” and “wip”. The fix is two layers: a template that prompts you with the format every time you commit, and a hook that rejects anything malformed. The first is a nudge; the second is a wall.
1. A commit template to prompt the format
Git can pre-fill the commit editor with a template. Point a config key at a file:
git config --global commit.template ~/.gitmessage
Then create ~/.gitmessage:
# <type>(<scope>): <subject> ← 50 chars max, imperative, lowercase
#
# <body: the WHY, wrapped at 72 chars>
#
# <footer: BREAKING CHANGE: … / Refs: #123>
#
# Types: feat fix docs style refactor perf test build ci chore revert
Every line starting with # is stripped by Git, so the whole thing is a checklist that
never lands in the actual message. Now git commit (no -m) opens an editor already
showing the format you’re supposed to follow.
What this does: --global writes the key to ~/.gitconfig so it applies to every
repo. For a per-project template (checked into the repo so the team shares it), drop the
--global and commit a .gitmessage to the root:
git config commit.template .gitmessage
The catch: commit.template is a local config, so a fresh clone won’t pick it up
automatically. That’s exactly why the next layer exists.
2. The wall: a commit-msg hook
A template is a polite suggestion. To actually reject a bad message, use the
commit-msg hook — Git runs it with the path to the message file, and a non-zero exit
aborts the commit. Create .git/hooks/commit-msg:
#!/usr/bin/env bash
# Reject commit messages that don't follow Conventional Commits.
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: .{1,50}'
first_line=$(head -n1 "$1")
if ! grep -qE "$pattern" <<< "$first_line"; then
echo "✗ Commit message does not follow Conventional Commits." >&2
echo " Expected: <type>(<scope>): <subject>" >&2
echo " Got: $first_line" >&2
exit 1
fi
Make it executable:
chmod +x .git/hooks/commit-msg
What this does, line by line:
patternis a regex for the spec: one of the allowed types, an optional(scope), an optional!for breaking changes, then:and a 1–50 char subject.head -n1 "$1"reads the first line of the commit message —$1is the file Git hands the hook.grep -qEtests the line against the pattern quietly; if it doesn’t match, the hook prints the offending line andexit 1aborts the commit.
Try it: git commit -m "fixed stuff" is now rejected, while
git commit -m "fix(auth): reject expired tokens" sails through.
3. Sharing hooks with the whole team
Here’s the problem that bites everyone: .git/hooks/ is not version-controlled. Your
teammate clones the repo and gets none of your hooks. Two ways to fix that.
Option A — core.hooksPath (plain Git, no dependencies): keep hooks in a tracked
directory and point Git at it.
mkdir .githooks
mv .git/hooks/commit-msg .githooks/commit-msg
git add .githooks/commit-msg
git config core.hooksPath .githooks
The one manual step remaining is that each clone must run
git config core.hooksPath .githooks once — so add it to your README or a make setup
target.
Option B — a hook manager (lefthook / husky): these wire the config step into
npm install (or their own install command) so it’s automatic. A lefthook.yml:
commit-msg:
commands:
conventional:
run: npx commitlint --edit {1}
4. commitlint, if you want a real ruleset
The hand-rolled regex above is fine, but commitlint
gives you the full spec, configurable rules, and clear error messages. Minimal setup:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
Then have the commit-msg hook call npx commitlint --edit "$1" instead of the regex.
You trade a Node dependency for a maintained, battle-tested validator — worth it on a
team, overkill for a solo repo.
Caveats and best practices
- Hooks are local and bypassable. Anyone can run
git commit --no-verifyto skip them. Hooks catch honest mistakes, not determined ones — the real enforcement is a CI check on the PR (runcommitlintagainst the range). Belt and suspenders. - Don’t validate merge commits. Add an early
[[ "$first_line" == Merge* ]] && exit 0if auto-generated merge messages trip the hook. - Keep the template short. A wall of comments trains people to delete the whole block without reading it. Five lines of reminder beats twenty.
- Document the one-time setup. With
core.hooksPath, a clone that skips the config step silently gets no enforcement. Make it part of onboarding.
Conclusion
# the nudge
git config commit.template .gitmessage
# the wall (shared)
git config core.hooksPath .githooks # commit-msg validates the format
A template reminds you; a hook stops you; CI is the backstop that can’t be bypassed.
Layer all three and “wip” never reaches main again — which is the whole point of
typing structured commits in the first
place.