A commit message is not a formality. It is a permanent, searchable record of why a change was made. The code tells you what changed — the commit message is the only place in the codebase where the reason is recorded. When a developer six months from now traces a bug to a specific commit and reads "fix stuff" or "wip" or "asdf," the history has failed them. They have to reconstruct the intent from the code, which takes time and produces uncertainty.
Conventional Commits is a specification for commit messages that makes the history machine-readable as well as human-readable. It is simple enough to internalize in an hour and generates enough value over time that it is worth enforcing with tooling.
A conventional commit message has this structure:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
The type is mandatory and describes what kind of change the commit represents:
feat— a new feature (triggers a MINOR version bump in semantic versioning)fix— a bug fix (triggers a PATCH version bump)docs— changes to documentation onlyrefactor— code changes that neither fix a bug nor add a featuretest— adding or updating testschore— maintenance tasks that do not affect production code (updating dependencies, configuring tools)perf— performance improvementsci— changes to CI/CD configurationbuild— changes to the build system
A breaking change is indicated by appending ! after the type or scope (feat!: change authentication API) or by including BREAKING CHANGE: in the footer. It triggers a MAJOR version bump.
The scope is optional and specifies which part of the codebase was affected: feat(auth): add refresh token rotation is more informative than feat: add refresh token rotation for a large repository with many modules.
The description is written in the imperative mood, lowercase, with no period at the end. "add refresh token rotation" not "Added refresh token rotation." The convention comes from git's own generated messages ("Merge branch..." not "Merged branch...") and from the fact that it reads naturally as completing the sentence "If applied, this commit will [description]."
feat(auth): add refresh token rotation with reuse detection
Implements single-use refresh tokens. Each rotation issues a new
refresh token and marks the previous one as replaced. If a replaced
token is presented (reuse), the entire token family is revoked and
a security event is logged.
Closes #142
fix(api): return 404 instead of 403 for cross-tenant resource access
Returning 403 confirmed to an attacker that the resource exists in
another tenant. Changed to 404 for all resource ownership checks to
avoid information leakage.
SECURITY: Prevents tenant enumeration via HTTP status codes.
refactor(orders): extract order validation to service layer
Moved inline validation logic from the controller to OrderService.
No behavior change — this is groundwork for the upcoming batch
order processing feature (see #156).
chore: upgrade dependencies to latest patch versions
No breaking changes. See package-lock.json diff for details.
Notice what these messages have in common: they explain the reasoning, not just the action. The fix example explains why 404 is the right choice. The refactor example explains why the refactor was done and what it enables. A developer reading the history a year from now can understand not just what happened but why — without needing to ask anyone.
fix bug
update
wip
various fixes
addressing pr comments
These messages are noise in the history. They tell you nothing about what was changed or why, they cannot be used to generate changelogs, and they cannot be used to trigger automated versioning.
The "addressing PR comments" antipattern is worth calling out specifically. PR review responses are not meaningful history. Before merging, squash the review response commits into meaningful, logical commits that describe the actual changes. The merge commit history should read as if the code had been written correctly the first time — each commit a deliberate step, not a record of the review process.
The Conventional Commits specification was designed with automation in mind. A history of well-typed commits can be parsed by tools to:
Generate changelogs automatically. standard-version and semantic-release parse commit history to generate CHANGELOG.md entries grouped by type — features, fixes, breaking changes. This eliminates manual changelog maintenance for teams that version their work.
Trigger semantic version bumps automatically. If every fix commit bumps the patch version and every feat commit bumps the minor version, releases become a mechanical process rather than a judgment call.
Enable meaningful filtering. git log --oneline --grep="^feat" shows only feature commits. git log --oneline --grep="^fix" -- src/auth/ shows only bug fixes in the auth module. This kind of filtering is only useful if the history is consistently typed.
Provide context in tooling. GitHub, GitLab, and CI platforms can surface commit information in pull requests and deployment pipelines. A PR whose commits are all typed is easier to review and understand than one where every commit says "update."
The convention is worthless if it is not followed consistently. Individual discipline is not sufficient — conventions that depend on everyone remembering to follow them will be violated under deadline pressure. The enforcement must be automatic.
commitlint validates commit messages against the Conventional Commits specification before they are recorded:
npm install --save-dev @commitlint/cli @commitlint/config-conventional// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'scope-enum': [2, 'always', ['auth', 'api', 'db', 'ui', 'ci', 'docs']],
'subject-max-length': [2, 'always', 72],
},
};Wired to Husky's commit-msg hook:
# .husky/commit-msg
npx --no -- commitlint --edit $1A commit message that does not conform to the specification fails the hook and the commit is rejected before it is recorded. The developer gets immediate feedback with a clear error message explaining what is wrong.
The scope-enum rule is optional but valuable for larger projects: it enforces that scopes come from a predetermined list, preventing the inconsistency of feat(Auth), feat(auth), feat(authentication) all referring to the same module.
The body of a commit message is where the real value lives for complex changes. Many developers never write one — the convention feels like overhead. The question to ask before committing a significant change: would a developer encountering this commit six months from now understand why this change was made, without having to read the full diff and reverse-engineer the intent?
If the answer is no, write a body. The body does not need to be long — three sentences is often sufficient. It needs to answer: what problem was this solving, and why was this approach chosen over alternatives?
- Conventional Commits Specification. https://www.conventionalcommits.org/ — Full specification, type definitions, examples.
- Semantic Versioning Specification. https://semver.org/ — MAJOR.MINOR.PATCH versioning, relationship to commit types.
- commitlint Documentation. https://commitlint.js.org/ — Configuration reference, rule definitions.
- semantic-release Documentation. https://semantic-release.gitbook.io/semantic-release/ — Automated versioning and changelog generation.
- Hunt, A. & Thomas, D. (2019). The Pragmatic Programmer (20th Anniversary Ed). Addison-Wesley. — Version control habits, commit discipline.
- Chacon, S. & Straub, B. (2014). Pro Git (2nd Ed). Apress. Free online: https://git-scm.com/book/en/v2 — Git history, commit message conventions.