commit-msg Hooks for Commit Message Validation
AI generated
git
HEAD
Git · Hooks · Commit Message · Code Quality
commit-msg Hooks for Commit Message Validation
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.

12 min. read commitlint · Husky · Conventional Commits Git Hooks · CI/CD · semantic-release

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.

11. FAQ: commit-msg Hooks for Commit Message Validation

1What does the commit-msg hook do, compared to pre-commit?
pre-commit checks staged changes before the message exists, e.g. linting. commit-msg checks the finished message itself, after it's written but before the commit object.
2How do I install commitlint with Husky?
Install @commitlint/cli, @commitlint/config-conventional, and husky, create commitlint.config.js, run npx husky init, and put the commitlint --edit "$1" call in .husky/commit-msg.
3What is the Conventional Commits format?
A type(scope): subject pattern with fixed types like feat, fix, docs, refactor. Makes commits machine-readable for versioning and changelog tools.
4Why does commitlint reject my commit?
Usually a violated rule like type-enum, subject-case, or header-max-length. The error message names the specific rule that failed.
5How do I bypass the hook in an emergency with --no-verify?
git commit --no-verify skips all client-side hooks. Reasonable for genuine emergencies, risky as a habit because it breaks release automation.
6Can the commit-msg hook be bypassed entirely?
Yes, locally at any time via --no-verify. A CI check at the pull request level or a server-side hook reliably closes that gap.
7Does commit-msg work with git commit -m and the editor?
Yes. Either way the message lands in a temporary file first, and the hook receives its path as $1, regardless of how it was entered.
8How does this interact with semantic-release?
semantic-release derives version number and changelog automatically from feat:, fix:, and BREAKING CHANGE: markers. Invalid commits are simply ignored.
9What happens on git commit --amend?
The hook runs again, since the changed message is validated again before the revised commit object is created.
10Which commitlint rules can be customized?
Practically all rules from @commitlint/config-conventional, such as type-enum, scope-enum, header-max-length, and subject-case, individually in the rules object.