Conventional Commits, End to End
· Jerwin Arnado ·
Open a year-old project and run git log --oneline. If it reads like this —
a1b2c3d fix
d4e5f6a more changes
b7c8d9e wip
e0f1a2b asdf
c3d4e5f final fix for real this time
— you already know the cost. Nobody can tell what shipped, what broke, or which commit to revert without reading every diff. Conventional Commits is the cheapest fix: a tiny grammar for the commit subject line that a human can scan and a machine can parse. No new tooling required to start — just a convention you and your team agree to type.
What Conventional Commits actually is
It’s a specification (currently 1.0.0) that says a commit message should look like:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
That’s the whole idea. The first line carries a type (what kind of change), an optional scope (what part of the codebase), and a short description. The structure is rigid enough for a script to read, loose enough that writing it never slows you down once it’s habit.
A real one from this very site’s history:
Show per-post view count from GoatCounter
— which, tightened to spec, becomes:
feat(blog): show per-post view count from GoatCounter
Now a changelog generator knows it’s a feature, knows it touched the blog, and knows the one-line summary, all without a human in the loop.
Anatomy of a message
| Part | Required? | Example | Purpose |
|---|---|---|---|
| type | yes | feat |
The category of change — drives versioning |
| scope | no | (auth) |
The area touched — a module, package, or feature |
| description | yes | add passkey login |
Imperative, lowercase, no trailing period |
| body | no | “Explains the why…” | Context the subject can’t hold |
| footer | no | BREAKING CHANGE: … |
Metadata: breaking changes, issue refs |
Two rules carry most of the value: keep the subject ≤ 50 characters so it never
truncates in git log, and write the description in the imperative mood — “add”,
not “added” or “adds”. The trick: a good subject completes the sentence “If applied,
this commit will ___.” “If applied, this commit will add passkey login.” Reads right.
The types
The spec mandates only two (feat and fix); the rest come from the widely-used
Angular convention and are worth adopting wholesale so your whole team speaks one
vocabulary:
| Type | Use it for | Version bump |
|---|---|---|
feat |
A new feature for the user | MINOR |
fix |
A bug fix for the user | PATCH |
docs |
Documentation only | none |
style |
Formatting, whitespace — no code-meaning change | none |
refactor |
Code change that neither fixes a bug nor adds a feature | none |
perf |
A change that improves performance | PATCH |
test |
Adding or correcting tests | none |
build |
Build system or dependencies (Composer, npm) | none |
ci |
CI config and scripts | none |
chore |
Maintenance that doesn’t touch src or tests |
none |
revert |
Reverts a previous commit | varies |
The “version bump” column is the payoff and we’ll come back to it. The short version:
feat and fix are the only types that change what your users see, so they’re the
only ones that move the version number on their own.
Scope, body, and footer
Scope is a noun in parentheses naming the part of the codebase — pick a convention
and stick to it. In a Laravel app that might be (auth), (billing), (api); in a
monorepo it’s usually the package name. Keep the set small and consistent, or it stops
being useful.
fix(api): return 422 instead of 500 on validation failure
Body is where the why lives. The subject says what changed; the body says what problem it solves and what you ruled out. Wrap at ~72 characters, separate it from the subject with one blank line:
fix(api): return 422 instead of 500 on validation failure
The validator threw before the response macro ran, so clients got a
generic 500 with no field errors. Catch ValidationException in the
handler and render the standard error envelope instead.
Footer carries machine-readable metadata — issue references and, most importantly, breaking changes.
Refs: #214
Reviewed-by: Jerwin
Breaking changes
This is the part that earns its keep. A breaking change is signalled one of two ways:
a ! after the type/scope, or a BREAKING CHANGE: footer (or both):
feat(api)!: drop the v1 token endpoint
BREAKING CHANGE: /api/v1/token is removed. Migrate to /api/v2/auth,
which returns a Sanctum token instead of the legacy JWT.
Either marker bumps the MAJOR version, no matter the type. That ! is a one-character
flag that tells every downstream consumer “read this before you upgrade” — the kind of
signal the whole open-source supply chain
relies on staying honest.
Why bother
Three concrete payoffs, in order of how soon you feel them:
- A readable history, today.
git log --onelinebecomes a scannable record. You cangit log --grep '^feat'to list every feature, or filter by scope to see everything that touched billing. The discipline pays off the first time you bisect a regression and the commit subjects actually tell you something. - An automated changelog. Tools like
git-clifforstandard-versionread the type and scope of every commit since the last tag and generate a groupedCHANGELOG.md— Features, Bug Fixes, Breaking Changes — with zero hand-editing. - Automated semantic versioning. This is the big one. The type-to-bump mapping in
the table above isn’t decoration: a release tool scans your commits and computes the
next version automatically. A
fixsince the last release means1.4.2 → 1.4.3. Afeatmeans1.4.2 → 1.5.0. Any breaking change means1.4.2 → 2.0.0. The version number stops being a judgment call and becomes a function of what you actually merged.
That third point is why I treat the convention as non-negotiable on anything I publish. It’s the same instinct as the verification discipline AI workflows forced on me: let the machine handle the bookkeeping it’s better at, so the version number can’t drift from reality.
Good and bad, side by side
# Bad — vague, past tense, no type
fixed the login bug
# Good
fix(auth): reject expired tokens on refresh
# Bad — two unrelated changes in one commit
feat: add dark mode and fix the footer spacing
# Good — split them
feat(ui): add class-based dark mode toggle
fix(ui): correct footer spacing on mobile
# Bad — type lies about the change
chore: rewrite the entire payment flow
# Good
refactor(billing): extract the Paymongo gateway behind an interface
The recurring sin is the bundled commit — two changes, one message. Conventional Commits gently forces atomic commits, because a single message can only honestly claim one type. That pressure toward one-change-per-commit is half the benefit.
Caveats and team adoption
- The convention is worthless if it’s not enforced. A repo where half the commits
follow the spec and half say “wip” gets you the worst of both — tooling that can’t
trust the history. Enforcement (a
commit-msghook,commitlint) is the natural next step; that’s its own post. - Don’t over-scope. A sprawling, inconsistent set of scopes is noise. If you can’t name the scope confidently, omit it — scope is optional for a reason.
choreis not a junk drawer. When everything becomeschore, the type column stops meaning anything. Reach for the specific type first.- Squash-merging? Make sure the squashed commit’s final message follows the spec —
the individual branch commits matter less than the one that lands on
main.
Conclusion
Conventional Commits is the rare discipline that costs almost nothing and compounds. The minimum viable adoption is one sentence: every commit subject starts with a type.
<type>(<scope>): <imperative, lowercase description ≤ 50 chars>
Start with just feat and fix. Add the rest of the vocabulary as it comes up. The
day you wire a changelog or a release tool onto a year of well-typed commits — and it
spits out a correct version number and a grouped changelog with no hand-editing — is
the day the habit pays for every keystroke it ever cost.