Demystifying Conventional Commits

30. December, 2023 6 min read Study

Commit messages a machine can read

Most git histories I've inherited read like a diary written by five people who never spoke to each other. "fix stuff", "wip", "final fix 2". You can live with that. What you can't do is hand it to a tool and ask what changed between two releases.

Conventional Commits is a small specification that fixes exactly that one problem. It puts a machine-readable prefix on the first line of every commit and leaves the rest of the message alone. That’s it. There’s no framework to adopt and nothing to run; a team can start using it this afternoon and only later decide whether to automate anything on top.

I’d like to go through the format, the parts people usually get wrong, and where I think the convention stops paying for itself.

The shape of a message

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

The type is the only genuinely load-bearing part. feat means a new capability and maps to a MINOR bump under semantic versioning. fix means a bug fix and maps to a PATCH. Everything else is convention rather than specification, though most teams settle on roughly the same set: docs, refactor, perf, test, build, ci, chore and revert.

The scope in parentheses narrows it down, which is the difference between a changelog you can skim and one you can’t:

feat(auth): add support for hardware security keys
fix(parser): handle empty arrays in the query string
docs: describe the migration path from v1
refactor(api): collapse the two response builders into one

Keep the description in the imperative and short. Commitlint’s conventional config caps the header at 100 characters, which is generous enough that hitting it usually means you’re describing two commits.

Breaking changes, properly

This is the part I see done wrong most often, and it’s worth being precise about because a mistake here silently breaks your versioning.

A breaking change is marked with an exclamation mark after the type or scope:

feat(api)!: require Node 18 or newer

Or with a BREAKING CHANGE: footer, which gives you room to explain the migration:

feat(api): switch the client to native fetch

BREAKING CHANGE: Node 16 is no longer supported. Upgrade to
Node 18 or pin the previous major.

You can use both together, and for anything with a real migration path you should; the ! catches the reader’s eye and the footer is what ends up in the changelog.

What does not work is dressing a breaking change up as a chore, which is the pattern I keep running into:

chore: upgrade library to v2.0.0

Semantically that’s just wrong, but the practical consequence is worse. Run that through semantic-release and its default rules produce no release at all. chore isn’t in the list of types that trigger a version bump. Your dependency bump quietly ships to whoever installs your package next with no major, no minor, no changelog entry, and no warning. The commit that most needed to be visible is the one that disappears.

What the convention buys you

Once messages are structured, a few things become possible that weren’t before.

Versioning without a meeting. Tools read the commits since the last tag and work out the bump themselves. Nobody argues about whether the release is a 1.4.0 or a 2.0.0, because the argument already happened when the commit was written.

Changelogs that write themselves. semantic-release groups commits by type, drops the noise, and produces something you can put in front of users.

A history you can query. git log --oneline --grep '^fix' v1.2.0..HEAD is a perfectly good bug report for a release, and it costs nothing to run.

The subtler benefit is the one that shows up in review. Writing feat or fix at the start of a message forces you to decide what the change actually is, and if you can’t decide, the commit is usually doing two things and wants splitting.

Tooling

You need none of this to start. Once the habit is there, three tools do most of the work.

Commitizen replaces git commit with a prompt that walks you through type, scope and description. Good for onboarding, mildly irritating once you’ve internalised the format.

commitlint checks messages against the convention and rejects the ones that don’t fit. Paired with Husky it runs on the commit-msg hook, not pre-commit, since the message doesn’t exist yet when pre-commit fires:

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky install
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'

With a one-line config:

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
};

And semantic-release reads the log on CI, decides the next version, writes the changelog, tags and publishes. Its default mapping is worth memorising, because it’s the thing that turns your commit messages into real consequences:

Commit Release
fix: patch
perf: patch
feat: minor
! or BREAKING CHANGE: major
anything else none

Where I’d skip it

The convention has a cost, and it isn’t the learning curve. People pick the format up in a day. The cost is that once a tool is publishing releases off your commit messages, a sloppy message becomes a wrong version number, and fixing a bad tag on a published package is a considerably worse afternoon than writing a careful commit would have been.

So: any library other people install, yes, without hesitation. A repository with more than three people committing to it, yes. My own weekend projects, no. Nothing downstream reads those commits and I’m not going to lint myself into writing chore(deps): on a Sunday.

Starting on an existing repository

You don’t need to rewrite history, and please don’t. Turn on commitlint, let the old messages stay as they are, and set the initial version by hand at the first release so the tooling has a baseline. Everything from that tag forward will be clean, which is all anyone actually needs.

‘Till next time!