Enforcing Conventional Commits before the commit exists
Waving commit messages through without validation builds up months of history full of entries like fix and wip that are useless for changelogs and automated releases alike. The commit-msg hook checks every message before the commit is created, and commitlint together with Husky enforces a consistent format that tools like semantic-release can reliably parse.
Table of Contents
- 1. Context: what the commit-msg hook is for
- 2. Hook mechanics: when it fires and what it receives
- 3. Enforcing Conventional Commits with commitlint
- 4. Installing Husky v9 and wiring up the hook
- 5. Detecting and rejecting vague commit messages
- 6. The emergency exit: git commit --no-verify
- 7. Changelog generation and semantic-release
- 8. CI integration: checking commitlint server-side too
- 9. commit-msg hooks compared side by side
- 10. Summary
- 11. FAQ
1. Context: what the commit-msg hook is for
Git hooks fall roughly into two families: client-side hooks that run locally on each developer's machine, and server-side hooks that run against the remote repository. The commit-msg hook belongs to the first family and sits in the sequence between pre-commit and post-commit: pre-commit checks the staged changes before any message has been written at all, while commit-msg checks the finished message after it has been written, but before Git turns it into a commit object. That narrow window is exactly where a commit can still be rejected without anything in the repository having changed.
Without validation, months of history accumulate with messages like fix, wip, or asdf that are no longer meaningful to anyone, not to code reviewers and not to tools that parse the history programmatically. The commit-msg hook is the earliest point where this can be prevented, locally, for free, and without a CI job having to run for minutes just to report the same mistake.
2. Hook mechanics: when it fires and what it receives
As soon as git commit is invoked, whether with -m "message" or through the opened editor, Git first writes the commit message to a temporary file, usually .git/COMMIT_EDITMSG. Only after that does Git call the commit-msg hook, passing the path to that file as the single parameter $1. The hook reads the file, checks its contents, and returns an exit code: 0 means approval, any other value aborts the commit before the commit object lands in the object database. The hook can even rewrite the file itself, for example to append a ticket number automatically.
The order relative to neighboring hooks matters: prepare-commit-msg runs before the editor even opens and can insert a template, for instance the branch name. commit-msg runs afterward and sees the final message as confirmed by the developer. post-commit only runs once the commit object already exists, so it can no longer abort anything. Anyone who wants validation must therefore hook into commit-msg, not post-commit.
3. Enforcing Conventional Commits with commitlint
Conventional Commits is a convention for commit messages following the pattern type(scope): short description, for example feat(checkout): add express payment button or fix(api): handle null response from pricing service. The common types are feat, fix, docs, style, refactor, perf, test, build, ci, and chore. This structure is machine-readable, which fundamentally sets it apart from a classic free-text commit: a script can decide whether a release triggers a major, minor, or patch version purely based on the type.
commitlint is the standard tool for enforcing this format automatically. With @commitlint/cli as the execution tool and @commitlint/config-conventional as the rule set, commitlint checks every message against rules such as type-enum (only allowed types), subject-case (no uppercase letter at the start of the subject), and header-max-length (usually 100 characters). If a rule fails, commitlint prints a precise error naming the violated rule, so the developer immediately knows what to fix.
// commitlint.config.js - enforce Conventional Commits format
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// Only allow these commit types
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'],
],
// Subject must not start with an uppercase letter
'subject-case': [2, 'always', 'lower-case'],
// Subject must not end with a period
'subject-full-stop': [2, 'never', '.'],
// Header (type + scope + subject) max length
'header-max-length': [2, 'always', 100],
// Scope is optional but must be lowercase when present
'scope-case': [2, 'always', 'lower-case'],
// Body lines must be wrapped, but a single-line body is fine
'body-max-line-length': [1, 'always', 100],
},
};
4. Installing Husky v9 and wiring up the hook
Husky solves a structural problem with Git hooks: .git/hooks/ is not versioned by Git itself, so every clone of a repository starts out with no hooks at all. Husky moves the hook scripts into a versioned .husky/ directory and automatically points core.hooksPath at it whenever npm install runs, via the prepare script in package.json. That means every team member gets the same hooks active right after cloning, with no manual extra step.
Since Husky v9, a single executable file .husky/commit-msg is enough, without the earlier shebang boilerplate. Its content calls commitlint with the file path it receives: npx --no -- commitlint --edit "$1". The --no flag on npx prevents npx from silently fetching a version from the registry when the local package is missing, which would otherwise slow down every commit unnecessarily or even block it entirely without an internet connection. The file must be executable, so chmod +x .husky/commit-msg is mandatory right after creating it.
#!/usr/bin/env sh
# .husky/commit-msg - validate the commit message file against commitlint rules
# $1 is the path to the temporary commit message file (e.g. .git/COMMIT_EDITMSG)
npx --no -- commitlint --edit "$1"
{
"name": "storefront-app",
"private": true,
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"husky": "^9.0.11"
}
}
5. Detecting and rejecting vague commit messages
commitlint with the default configuration enforces the format, but it does not automatically check whether the subject actually says anything meaningful. A message like fix: fix formally satisfies every default rule and still gets waved through, even though it carries zero information. Messages like fix, wip, asdf, or update are the actual core of the problem commit-msg hooks are meant to solve, and they deserve an additional, project-specific check.
A custom rule extension for commitlint, or a simple Node script running in the same hook, can enforce a blacklist of known filler words plus a minimum length for the subject. In practice, a combination of two checks works well: first, a list of generic terms such as fix, wip, update, asdf, test, tmp, rejected outright as a standalone subject, and second, a minimum length of around ten characters for the free-text portion after the type. This catches the most obvious cases without unnecessarily blocking short but genuinely descriptive messages.
#!/usr/bin/env node
// scripts/validate-commit-msg.js - reject vague or placeholder commit messages
// Called from .husky/commit-msg as an extra layer next to commitlint
const fs = require('fs');
const VAGUE_SUBJECTS = ['fix', 'wip', 'asdf', 'update', 'test', 'tmp', 'stuff', 'changes'];
const MIN_SUBJECT_LENGTH = 10;
const commitMsgFile = process.argv[2];
const message = fs.readFileSync(commitMsgFile, 'utf8').trim();
const firstLine = message.split('\n')[0];
// Extract subject after "type(scope): " or "type: "
const match = firstLine.match(/^\w+(\([\w.-]+\))?!?:\s*(.+)$/);
const subject = match ? match[2].trim() : firstLine;
if (VAGUE_SUBJECTS.includes(subject.toLowerCase())) {
console.error(`Commit rejected: "${subject}" is a placeholder message.`);
console.error('Describe what changed and why, e.g. "fix(cart): prevent duplicate line items on retry".');
process.exit(1);
}
if (subject.length < MIN_SUBJECT_LENGTH) {
console.error(`Commit rejected: subject is too short (${subject.length} chars, minimum ${MIN_SUBJECT_LENGTH}).`);
process.exit(1);
}
process.exit(0);
6. The emergency exit: git commit --no-verify
Git's --no-verify flag (short form -n) provides a deliberate way to skip all client-side hooks for a single commit, including pre-commit and commit-msg. This escape hatch exists for a good reason: in a genuine emergency, such as a critical production hotfix in the middle of the night, a misconfigured hook or a blocking linter must not be allowed to hold up the entire deployment process. It's also a legitimate tool for deliberate WIP commits on a private feature branch that will be squashed via rebase -i before the merge anyway.
It becomes a problem once --no-verify turns from an emergency exit into a habit. Every message that bypasses the hook can break the chain that changelog generation and semantic-release depend on: a single commit without a valid type is enough for an automated release tool to silently ignore it or, worse, misclassify it. The history also becomes harder to review, because reviewers can no longer tell from the diff alone whether a commit was deliberately left unvalidated or whether the hook was simply never installed in the first place.
7. Changelog generation and semantic-release
semantic-release and similar tools like standard-version read the commit history since the last release and derive the next version number according to Semantic Versioning: a fix: commit bumps the patch version, a feat: commit bumps the minor version, and a commit with BREAKING CHANGE: in the footer or a ! after the type forces a major version. From those same commits, the tool automatically generates a structured CHANGELOG.md entry, grouped by type, with zero manual upkeep.
This automation is only as reliable as the underlying commit messages. A commit with the message update stuff gets simply ignored by semantic-release because no known type can be recognized, even if the change itself was a breaking one. The result is a release that quietly omits an important change, or worse, a version number that doesn't match the actual scope of the change. The commit-msg hook is therefore not merely a style preference, but a prerequisite for release automation to work correctly at all.
8. CI integration: checking commitlint server-side too
Because --no-verify disables local hooks entirely, nobody can rely exclusively on the local commit-msg hook. Anyone who wants to guarantee that every merged commit actually matches the format adds a second validation layer in the CI pipeline, running independently of each developer's local setup. A GitHub Actions action like wagoid/commitlint-github-action, or a simple commitlint --from origin/main --to HEAD call, validates every commit in a pull request before it's allowed to merge.
This CI check is deliberately meant as a supplement, not a replacement: the local hook gives immediate feedback right at commit time, while the CI check closes the gap for cases where the hook is missing locally, gets bypassed, or was simply never installed. Anyone who needs even stronger guarantees can additionally set up a server-side update or pre-receive hook on the Git server itself, rejecting pushes with invalid commit messages outright. That's a separate topic with its own operational logic and is deliberately not covered in depth here.
# .github/workflows/commitlint.yml - validate all commits in a pull request
name: Lint commit messages
on:
pull_request:
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Validate current commit range
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
9. commit-msg hooks compared side by side
The effect of a consistently enforced commit-msg hook is best shown through concrete scenarios where validated and unvalidated commit histories diverge.
| Scenario | Without a commit-msg hook | With commitlint + Husky | Effect |
|---|---|---|---|
| Commit message "fix" | accepted, carries no meaning | rejected, missing type-enum | Traceable history |
| Changelog upkeep | maintained manually, often stale | generated automatically from feat:/fix: | No manual maintenance |
| Versioning | decided manually | semantic-release determines major/minor/patch | Consistent SemVer |
| Reviewing the history | individual commits barely understandable | type(scope): subject instantly readable | Faster review |
| CI parsing commit types | breaks silently on free text | robust thanks to enforced format | No silent failures |
The common thread across every row: without an enforced format, interpreting the history stays manual work; with commitlint and Husky, it becomes a byproduct that emerges automatically with every commit.
Mironsoft
Git workflow consulting, hook automation, and CI/CD for PHP and Magento teams
Commit messages your tools can actually rely on?
We set up commitlint and Husky in your repository, define project-specific rules against vague messages, and wire the check in as a CI gate too, so your changelog and versioning run automated and reliable.
Hook setup
Setting up commitlint, Husky, and project-specific validation rules
CI integration
Commit validation as a pull request gate in GitHub Actions or GitLab CI
Release automation
semantic-release and changelog generation from clean commit histories
10. Summary
The commit-msg hook for commit message validation solves a problem many teams only notice late: a commit history full of fix and wip is useless for both code review and automated releases. commitlint with @commitlint/config-conventional enforces the Conventional Commits format through clear rules like type-enum and header-max-length. Husky wires up this check via a versioned .husky/commit-msg script, so every clone of the repository automatically gets the same validation, with no manual extra step.
The --no-verify emergency exit remains necessary for genuine emergencies, but it becomes a risk the moment it turns into a habit: every bypassed commit can break the chain that changelog generation and semantic-release depend on. An additional CI check reliably closes that gap, independent of each developer's local setup, turning commit-msg validation into a two-layer safety net instead of a single, bypassable control.
commit-msg Hooks for Commit Message Validation, the Essentials at a Glance
Hook mechanics
Fires after the message is written, before the commit object exists. Receives the file path as $1, exit code decides approval.
Conventional Commits
type(scope): subject with fixed types like feat, fix, chore. commitlint checks against type-enum and friends.
Husky wiring
.husky/commit-msg with npx --no -- commitlint --edit "$1", activated automatically on every clone via prepare.
Bypass risk
--no-verify for genuine emergencies, but as a habit it breaks changelog and release automation.