Commitlint + Conventional Commits
Commitlint config that enforces Conventional Commits on every commit, with project-scope rules and a Husky commit-msg hook that runs in under a second.
3 min read · New · 👍 0
Conventional Commits is a deceptively boring spec. It says every commit message starts with a type (feat, fix, chore, …), optionally followed by a scope in parentheses, then a colon, then the subject. That's the entire content. What it buys you is enormous: machine-readable history, automatic changelog generation, deterministic semver bumps from BREAKING CHANGE: footers, and — most importantly — a commit log that can be scanned in two seconds instead of two minutes.
Commitlint is the linter that enforces the spec. It runs on every commit via a Husky commit-msg hook, rejects malformed messages before they hit .git/, and points the offender at the exact rule that failed. The total runtime budget is under a second on a modern laptop — fast enough that nobody resents the gate.
#// Where the Config Lives
Commitlint reads its config from commitlint.config.js (or .commitlintrc.js, .commitlintrc.json, the commitlint key in package.json — pick one and stick with it). For a monorepo, put the config at the repo root and let every package inherit it. There's no per-package customization to be done here.
export default {
extends: ['@commitlint/config-conventional'],
}That one-line config gets you the entire Conventional Commits spec — every type, every formatting rule, every body-line-length check. For most projects, it's enough. The customization below is for teams that want a closed scope vocabulary and a slightly stricter subject case.
#// The Husky Hook
Commitlint isn't doing anything until Husky wires it up. The commit-msg hook is a one-liner that pipes the message into commitlint's CLI:
#!/usr/bin/env sh
pnpm exec commitlint --edit "$1"The $1 is Git's path to the staged commit message file; --edit tells commitlint to read from that path. If the message fails any rule, the hook exits non-zero and Git aborts the commit — the message file stays edited in .git/COMMIT_EDITMSG, so you can git commit --reuse-message=ORIG_HEAD or just retry with -m to re-trigger validation.
#// Project-Scope Rules
The defaults are deliberately loose so they fit any project. For a monorepo with named workspaces, locking down the scope to a fixed enum makes the commit log dramatically more useful:
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'scope-enum': [
2,
'always',
[
'site',
'ui',
'cli',
'registry',
'docs',
'deps',
'config',
'release',
],
],
'subject-case': [2, 'never', ['pascal-case', 'upper-case']],
'body-max-line-length': [1, 'always', 100],
},
}// decision
Pin scope-enum to the workspace names, not free-form text
- Leave scope free-form: Every contributor invents their own vocabulary and the changelog becomes noisy
The rule severity levels are [level, applicability, value] — level 2 is an error (rejects the commit), level 1 is a warning (commit proceeds but with a yellow message), level 0 disables. The subject-case rule with 'never' flagged on pascal-case and upper-case keeps subject lines in sentence-case without forbidding initialisms (JWT, OAuth, URL). The body-max-line-length at level 1 is intentional — body wrapping is a code-review nice-to-have, not a hard block.
#// Common Errors
The three errors that fire most often have one-line fixes:
✖ scope must be one of [site, ui, cli, registry, docs, deps, config, release]
You typed a scope that isn't in the enum. Either pick an existing one or add the new scope to scope-enum. The discussion of "should this be its own scope" is the discussion you want to be having.
✖ subject may not be empty
The colon-and-space after the type was followed by nothing. You typed feat: and hit enter. Add a subject.
⚠ body's lines must not be longer than 100 characters
A line in the commit body is over 100 chars. The hook accepts it (level 1 = warning) but the message will look ugly in git log on an 80-column terminal. Wrap it.
#// Bypass Etiquette
git commit --no-verify bypasses every git hook including commitlint. There are exactly two situations where it's defensible: (a) you're committing a temporary save-point on a feature branch you're about to squash anyway, and (b) the linter itself has a bug. Outside those, every --no-verify commit is a future grep that won't find what it's looking for.
#// Releasing on Top of Conventional Commits
Conventional Commits' real payoff arrives at release time. Tools like changesets, semantic-release, or release-please parse the feat: / fix: / BREAKING CHANGE: footers and produce a changelog plus a semver bump automatically. Without the commit conventions, those tools are guessing; with them, the release becomes a CI job. The commitlint gate is what makes that downstream automation trustworthy — there's no point parsing commit types if half the commits don't have them.
// decisions
Enforce a fixed set of scope tokens via scope-enum
Free-form scopes drift — every contributor invents their own (auth, authz, login, signin). A fixed enum keeps the history greppable and makes the eventual changelog generation a string-match instead of a normalization pass. The cost is a one-line config update when a new scope is needed, which is roughly never.