Conventional Commits
A commit message is the only documentation guaranteed to travel with a change forever. Conventional
Commits turn that prose into a structured, machine-parseable record: a tool can read your history and
derive the next version number and a categorized changelog without a human touching either. This skill is
the deep reference for the spec, the trade-offs, and the failure modes. Heavy detail lives in
references/; copy-paste config in examples/; a runnable linter in scripts/.
Mental model
Every commit answers three questions, and the format maps one-to-one onto them:
| Question | Where it lives |
|---|---|
| What kind of change? | the type (feat, fix, …) |
| Where, narrowly? | the optional scope (feat(auth):) |
| Does it break callers? | the ! marker and/or BREAKING CHANGE: footer |
| Why, in prose? | the body |
| What does it reference/close? | the footer (Refs:, Closes:) |
The header is for machines and scanners; the body is for the next human. Get the header structurally correct and your release tooling does the rest for free.
The spec
<type>(<optional scope>)<optional !>: <subject>
<blank line>
<optional body — wrapped prose explaining the why, may span paragraphs>
<blank line>
<optional footer(s) — BREAKING CHANGE: …, Refs: #123, Closes: #456, Co-Authored-By: …>
Rules that the linter enforces (see scripts/lint-commit.mjs):
- Type is required, lowercase, from the allowed set below.
- Scope is optional, in parentheses, a lowercase noun for the affected area (
api,auth,deps). !before the colon flags a breaking change.- Subject follows
:(colon-space), is imperative mood, lowercase, no trailing period, and the whole header is ≤ 72 characters (50 is the ideal — it keepsgit log --onelineand GitHub from truncating).
The full grammar, footer tokens, and revert/merge conventions are in references/spec.md.
Allowed types
| Type | Use for | SemVer impact |
|---|---|---|
feat |
a new user-facing feature | MINOR |
fix |
a bug fix | PATCH |
docs |
documentation only | none |
style |
formatting, whitespace, no code change | none |
refactor |
code change that neither fixes a bug nor adds a feature | none |
perf |
a performance improvement | PATCH |
test |
adding or correcting tests | none |
build |
build system or dependencies | none |
ci |
CI configuration and scripts | none |
chore |
maintenance, no production code change | none |
revert |
reverts a previous commit | varies |
Any commit with a ! or BREAKING CHANGE: footer is a MAJOR bump, regardless of type. Keep the set
small and team-agreed — inventing per-developer types defeats the automation. See
references/breaking-changes-semver.md for the precise type → version mapping.
Why bother (the automation payoff)
The structure is not bureaucracy — it unlocks tooling you'd otherwise hand-maintain:
- Automated versioning:
semantic-release/ Changesets read the commits since the last tag and pick MAJOR / MINOR / PATCH from the types. No more "what should this version be?" debates. - Generated changelogs: commits group by type into a categorized
CHANGELOG.mdwith links to PRs and issues, written from the footers. - Scannable history: filtering by type answers "what features shipped this quarter?" in one command:
git log --oneline --grep '^feat' v1.4.0..HEAD # every feature since the last release
git log --oneline --grep 'BREAKING CHANGE' # every breaking change, ever
- Reviewable diffs: one logical change per commit means reviewers and
git bisectoperate on coherent units instead of tangled mega-commits.
Scoping commits (one logical change)
A perfect message on a tangled commit is still a bad commit. Each commit should be one coherent change
that builds and passes tests on its own — don't mix a refactor with a feature, or a fix with a formatting
sweep. This makes git revert, git bisect, and cherry-picks surgical instead of all-or-nothing. The
discipline of staging hunks (git add -p) to separate concerns is covered in references/scoping-commits.md.
Common pitfalls and failure modes
- Past tense / capitalized subject (
Added login) — the convention is imperative, lowercase:add login. A useful test: the subject should complete the sentence "If applied, this commit will ___". - A scope that's really a type (
feat(fix):) — scope is a place (auth), not a kind of change. - Forgotten breaking-change marker — renaming a public field as a plain
refactor:ships a MAJOR break as a no-bump release and silently breaks downstream consumers. Always add!+ aBREAKING CHANGE:footer explaining the migration. - Junk-drawer
chore:— usingchorefor everything erases the signal. A dependency bump that fixes a CVE is afix; a new capability is afeat. - Header over 72 chars — it truncates in
git log --oneline, GitHub, and changelog output. Move detail into the body. - Mega-commits ("WIP", "fixes") — unparseable by tooling and impossible to revert cleanly.
- Enforcing only on the final squash — if you squash-merge, the PR title becomes the commit; lint that, not just local commits, or the rule has no teeth.
When NOT to use / trade-offs
- Solo throwaway prototypes — the changelog/versioning payoff is zero; the ceremony is pure overhead.
- Squash-merge-only teams — per-commit discipline matters less; instead enforce the convention on PR titles via a CI check and let local commits be messy.
- Non-software repos (docs sites, infra-as-data) — the type vocabulary often doesn't fit; a lighter convention may serve better.
- The cost: it adds friction and requires a
commitlintgate plus team buy-in to stay consistent. Half-adopted, it's worse than nothing because the automation can't trust the data. Adopt it fully (with thecommit-msghook inexamples/commitlint.config.js) or not at all.
Files in this package
references/spec.md— full grammar: header, body, footers, revert/merge, FAQreferences/breaking-changes-semver.md—!vsBREAKING CHANGE:and exact type → SemVer mappingreferences/scoping-commits.md— splitting work into atomic commits withgit add -pexamples/commit-examples.md— annotated good and bad messages across every typeexamples/commitlint.config.js— zero-config-friendly commitlint setup + huskycommit-msghookscripts/lint-commit.mjs— runnable Node linter for a commit header, with--selftest
Pairs with the ci-pipeline-design skill (gate commits in CI), the changelog-writing skill (turn the
history into release notes), and the code-reviewer agent. See the
Conventional Commits spec for the canonical wording.