Skip to content

← Writing

engineering

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:

  1. pattern is 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.
  2. head -n1 "$1" reads the first line of the commit message — $1 is the file Git hands the hook.
  3. grep -qE tests the line against the pattern quietly; if it doesn’t match, the hook prints the offending line and exit 1 aborts 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-verify to skip them. Hooks catch honest mistakes, not determined ones — the real enforcement is a CI check on the PR (run commitlint against the range). Belt and suspenders.
  • Don’t validate merge commits. Add an early [[ "$first_line" == Merge* ]] && exit 0 if 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.