← All articles
GIT commitlint: Enforce Conventional Commit Messages Aut... 2026-03-04 · 3 min read · commitlint · conventional commits · git hooks

commitlint: Enforce Conventional Commit Messages Automatically

Git 2026-03-04 · 3 min read commitlint conventional commits git hooks ci/cd automation semantic versioning changelog developer tools

Inconsistent commit messages are a chronic problem on most projects: "fix stuff", "wip", "update", "asdf". These are useless for understanding history, impossible to parse for changelogs, and unhelpful for code review. commitlint enforces a consistent format automatically — commits that don't conform are rejected before they enter the repository.

Conventional Commits Format

commitlint typically enforces the Conventional Commits specification:

<type>(<scope>): <description>

[optional body]

[optional footer(s)]

Types:

Examples:

feat(auth): add OAuth2 login with Google
fix(api): handle empty response from user service
docs: update README with new configuration options
chore(deps): upgrade TypeScript to 5.4

Install

# Install commitlint and the conventional commits config
npm install --save-dev @commitlint/cli @commitlint/config-conventional

# or with pnpm
pnpm add -D @commitlint/cli @commitlint/config-conventional

Configuration

Create commitlint.config.ts (or .js/.cjs):

import type { UserConfig } from "@commitlint/types";

const config: UserConfig = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    // Customize rules here
    "type-enum": [
      2,           // severity: 2 = error
      "always",    // applicable: always
      ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "revert"],
    ],
    "scope-case": [2, "always", "lower-case"],
    "subject-max-length": [2, "always", 100],
    "body-max-line-length": [2, "always", 200],
  },
};

export default config;

Rule severity:

Git Hook: lefthook

The most effective way to enforce commitlint is via a git commit-msg hook. With lefthook:

# lefthook.yml
commit-msg:
  commands:
    lint:
      run: npx commitlint --edit {1}
npx lefthook install

Now every commit is validated before it's accepted. Non-conforming commits are rejected with a clear error message.

Git Hook: husky + lint-staged

If you're using husky:

npm install --save-dev husky
npx husky init

Add the commit-msg hook:

echo "npx commitlint --edit \$1" > .husky/commit-msg
chmod +x .husky/commit-msg

Validate a Message Manually

# Test a specific message
echo "feat(api): add pagination support" | npx commitlint

# Test the last commit
npx commitlint --from HEAD~1

CI Integration

Validate commits in CI to catch anything that bypassed local hooks:

# GitHub Actions
- name: Validate commit messages
  run: |
    npx commitlint \
      --from ${{ github.event.pull_request.base.sha }} \
      --to ${{ github.event.pull_request.head.sha }} \
      --verbose

This validates every commit in a PR, not just the latest.

Automatic Changelog Generation

Once commits follow Conventional Commits, you can generate changelogs automatically with conventional-changelog:

npm install --save-dev conventional-changelog-cli

# Generate CHANGELOG.md
npx conventional-changelog -p conventional -i CHANGELOG.md -s

Or with standard-version / release-please:

# standard-version bumps version and generates changelog
npx standard-version

This parses feat commits → MINOR version bump, fix commits → PATCH bump, feat! or BREAKING CHANGE: → MAJOR bump.

Scopes

Scopes make commit messages more precise and enable filtering:

feat(auth): add JWT refresh token rotation
fix(auth): prevent token reuse after logout
feat(billing): add Stripe subscription support
fix(billing): handle declined card gracefully

Define allowed scopes in config:

const config: UserConfig = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "scope-enum": [
      2,
      "always",
      ["auth", "billing", "api", "ui", "db", "infra", "docs"],
    ],
  },
};

Any scope not in the list is rejected. This enforces consistency across a team.

Breaking Changes

Signal breaking changes two ways:

# Method 1: exclamation mark after type
feat!: remove deprecated v1 API endpoints

# Method 2: BREAKING CHANGE footer
feat(api): restructure user response schema

BREAKING CHANGE: `user.name` field renamed to `user.displayName`

Both trigger a MAJOR version bump in automated versioning.

Custom Rules

const config: UserConfig = {
  extends: ["@commitlint/config-conventional"],
  plugins: ["commitlint-plugin-function-rules"],
  rules: {
    // Require issue reference in footer
    "footer-leading-blank": [1, "always"],

    // Custom: subject must not end with period
    "subject-full-stop": [2, "never", "."],

    // Custom: header must not start with uppercase (type is always lowercase)
    "header-case": [2, "always", "lower-case"],
  },
};

Team Workflow

The typical setup for a team project:

  1. commitlint.config.ts in repo root
  2. lefthook.yml with commit-msg hook
  3. CI job validates all PR commits
  4. release-please or standard-version generates changelogs on merge to main
  5. Semantic versioning is automated

Once in place, commit history becomes a structured data source — not just a narrative. Release notes write themselves.

Common Issues

"Could not find config file": Ensure commitlint.config.ts is in the repo root or specify with --config.

Hook not running: Verify lefthook/husky is installed and hooks are set up: ls .git/hooks/commit-msg.

Squash commits in CI: If you squash PRs, the merge commit needs to conform. Configure your GitHub merge strategy to use a conventional commit for the squash message.

Monorepos: Use scopes for package names: feat(web): ..., fix(api): .... Consider @commitlint/config-nx-scopes for Nx workspaces that auto-infer scopes from package names.

commitlint takes 10 minutes to set up and immediately improves the signal-to-noise ratio in git history. Combined with automated changelogs and semantic versioning, it makes releases significantly less manual.