commitlint: Enforce Conventional Commit Messages Automatically
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:
feat: New featurefix: Bug fixdocs: Documentation onlystyle: Formatting (no logic change)refactor: Code restructuring (no feature/fix)perf: Performance improvementtest: Adding or fixing testschore: Build, tooling, maintenanceci: CI configuration
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:
0= disabled1= warning2= error (blocks commit)
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:
commitlint.config.tsin repo rootlefthook.ymlwith commit-msg hook- CI job validates all PR commits
release-pleaseorstandard-versiongenerates changelogs on merge to main- 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.