Skip to content
◢ DISPATCH / CONVENTIONAL-COMMITS

Getting Started with Conventional Commits

Posted on September 17, 2026·Updated on September 25, 2026·18 MIN READ· VIEWS

◢ This post is also available in: 日本語

Getting Started with Conventional Commits

What Conventional Commits is

Conventional Commits is a convention for the first line of a commit message. There is very little to it: the first line takes this shape.

text
<type>(<scope>): <summary>

This blog's repository writes its commits this way. git log --oneline for the last eight commits looks like this.

terminal
❯ git log --oneline -8
98040a1 build(docker): follow the stable Dockerfile syntax channel (#1260)
a20cc78 test(ci): run the unit tests on every change (#1259)
d700e68 fix(actions): give the cache cleanup the permission it needs (#1257)
8abf64f ci(actions): install bun through mise, as everything else does (#1258)
0d5789d fix(actions): pass bare image names to the reusable workflow (#1256)
347c78c ci(actions): build the image with Docker GitHub Builder (#1255)
6fcfac0 ci(actions): build the Lambda image on a native arm64 runner (#1253)
66a7ae6 style(css): cap a code block at seven tenths of the viewport (#1251)
1 commands9 lines

Every line has the shape kind(where): summary. The leading build or fix tells you what kind of change a commit is without reading it, and the parentheses tell you where it landed.

The convention comes from the Angular commit message guidelines. The Angular team had settled on a format for their own use; Conventional Commits lifted it out so that any project could adopt it, and pinned it down as a specification. The current version of the spec is v1.0.0.

The format

A whole commit message has three parts: a header, a body and a footer. Only the header is required.

text
<type>(<scope>): <summary>
<blank line>
<body>
<blank line>
<footer>

type

The kind of change. The specification itself defines only two, feat and fix; everything else is up to the project. In practice, though, most projects use the list that commitlint's config-conventional ships with, as is.

typeMeaning
featA new feature
fixA bug fix
docsDocumentation only
styleChanges that do not affect behaviour (whitespace, formatting, semicolons)
refactorA code change that neither fixes a bug nor adds a feature
perfA change that improves performance
testAdding or correcting tests
buildChanges to the build system or external dependencies
ciChanges to CI configuration and scripts
choreOther maintenance, with no production code change
revertReverting an earlier commit

feat and fix are the two the spec pins down because they are the two that map onto SemVer, which comes up below.

scope

What part of the project changed. It is optional. Nothing dictates what goes here — a package name, a directory, a feature — whatever unit answers "where" for that project. This blog uses blog, mdx, css, actions, docker and so on.

summary

A short description of the change. Imperative, present tense, lowercase first letter, no trailing period. Writing "what applying this commit does" rather than "what I did" produces the imperative naturally: fix the login button, not fixed the login button.

The body says why. What changed is in the diff, so the body holds what the diff cannot: the motivation, and how the previous behaviour compares to the new one.

The footer holds breaking-change declarations and issue references. A line starting with BREAKING CHANGE: marks the commit as containing one.

text
feat(api)!: drop the v1 endpoints

BREAKING CHANGE: /v1/* is gone. Move to /v2/*, which returns the same
shape with ids as strings.

The ! after the type is the other breaking-change marker. Either one on its own is valid, but the ! is for whoever reads only the first line and the footer is where the migration goes, so writing both is the kind thing to do.

What it buys you

The history can be filtered

With the first line in a fixed shape, git log --grep can pull out one kind of commit. Here are just the bug fixes in this blog's repository.

terminal
❯ git log --oneline --grep='^fix(' -6
d700e68 fix(actions): give the cache cleanup the permission it needs (#1257)
0d5789d fix(actions): pass bare image names to the reusable workflow (#1256)
45595e5 fix(actions): turn off the colour commitlint writes into the comment (#1247)
a06ac7b fix(blog): place the index panel from the left, where the scrollbar is not (#1244)
fd583b8 fix(mdx): give each render its own heading id counter (#1235)
70866dc fix(blog): keep the desktop tabs at the right edge of the header (#1221)
1 commands7 lines

Include the scope and you get, say, only the features added to the blog itself.

terminal
❯ git log --oneline --grep='^feat(blog)' -5
ad390e5 feat(blog): add the Oh My Posh introduction (#1250)
ff487b5 feat(blog): fill the bento grid with five posts (#1240)
cd7f7b8 feat(blog): add the Sapling introduction (#1237)
5e7e11c feat(blog): open every post image at full size on click (#1236)
e87fd51 feat(blog): add the Jujutsu introduction (#1234)
1 commands6 lines

"What did we fix recently" and "when did this feature go in" are answered from the history alone, without walking diffs.

The CHANGELOG writes itself

Because type and scope are machine-readable, release notes can be generated from the history. Here is git-cliff pointed at this blog's repository with no configuration file at all. --unreleased restricts it to the commits after the latest tag.

terminal
❯ git cliff --unreleased --strip all
## [unreleased]

### 🚀 Features

- *(seo)* Give the locale subpages their own titles (#1151)
- *(seo)* Give each locale page its own description (#1154)
...
- *(blog)* Add the Sapling introduction (#1237)
- *(blog)* Fill the bento grid with five posts (#1240)
- *(blog)* Add the Oh My Posh introduction (#1250)

### 🐛 Bug Fixes

- *(common)* Align the manifest colours and name with the site (#1160)
- *(blog)* Declare the post route as one that blocks (#1166)
...
- *(actions)* Give the cache cleanup the permission it needs (#1257)

### 📚 Documentation

- *(common)* Remove explanatory code comments (#1148)
...
- *(blog)* Repair the details that made the posts contradict themselves (#1241)

### ⚡ Performance

- *(common)* Cut the default thumbnail to 7KB and cache what is served (#1184)
- *(css)* Stop shipping 871KB of font declarations on every page (#1185)
...
1 commands30 lines

One heading per type, the scope in front of each entry, the first letter of each summary capitalised. The only thing anyone wrote was the commit messages; nothing was prepared for this Markdown.

The version comes from the history

Conventional Commits lines up with Semantic Versioning.

CommitBump
fixPatch (1.2.3 → 1.2.4)
featMinor (1.2.3 → 1.3.0)
BREAKING CHANGE or !Major (1.2.3 → 2.0.0)

Here it is happening. In a small repository, one feat and one fix were stacked on top of a v1.2.3 tag. The build(git) between them is the commit that sets up commitlint and lefthook later in this article.

terminal
❯ git log --oneline
a2dda38 fix(api): return 404 for an unknown id
c5e0a22 feat(api): add a search endpoint
9568ccc build(git): check commit messages with commitlint through lefthook
667aefe chore: initial commit
1 commands5 lines

git cliff --bumped-version computes the next version from the commits since the tag and prints it.

terminal
❯ git cliff --bumped-version
v1.3.0
1 commands2 lines

There is a feat, so the minor goes up: v1.3.0. Now add the breaking-change commit from earlier.

terminal
❯ git log --oneline -1
c3bfba0 feat(api)!: drop the v1 endpoints
❯ git cliff --bumped-version
v2.0.0
2 commands4 lines

The ! bumps the major: v2.0.0. The CHANGELOG side marks that one commit too.

terminal
❯ git cliff --unreleased --strip all
## [unreleased]

### 🚀 Features

- *(api)* Add a search endpoint
- *(api)* [**breaking**] Drop the v1 endpoints

### 🐛 Bug Fixes

- *(api)* Return 404 for an unknown id

### 💼 Other

- *(git)* Check commit messages with commitlint through lefthook
1 commands15 lines

Nobody has to decide what number comes next. semantic-release and release-please take this computation, the tagging and the publishing, and run the whole thing from CI.

Here is that same history, to rearrange. Take a commit out of the release and both the version and the changelog follow.

git cliffinteractive

Press a commit to take it in or out of the release. One breaking change is all it takes.

Commits since the tag

Next version

1.2.3 → 1.3.0

because there is a feat

CHANGELOG

🚀 Features

*(api)* Add a search endpoint

🐛 Bug Fixes

*(api)* Return 404 for an unknown id

💼 Other

*(git)* Check commit messages with commitlint through lefthook

1.3.0 — because there is a feat

The type becomes a heading and the scope a prefix, and you only ever wrote commit messages

Writing gets easier too

It may sound backwards, but a fixed format makes writing the message easier, because you are filling in blanks.

Choosing the type is also the moment you find out whether the commit is one kind of change. If you want to write both feat and fix, that is the signal to split it in two. The convention pushes commits towards a consistent size.

Checking with commitlint

A convention is only worth having if it is followed. Rather than checking by eye, hand the message to commitlint.

bash
bun add -d @commitlint/cli @commitlint/config-conventional

@commitlint/config-conventional is the rule set for Conventional Commits. The configuration file is one line that extends it.

commitlint.config.tsts
export default {
  extends: ["@commitlint/config-conventional"],
}

Piping a message into it on standard input runs the check. Here is a message that does not follow the convention.

terminal
❯ echo "update stuff" | bun commitlint --verbose
⧗   --- input ---
update stuff
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
1 commands8 lines

Two problems: no type, and with no : either there is no subject to extract. The name in brackets is the rule, and each one can be relaxed or tightened individually in the config.

Here is a message that is closer, but not there.

terminal
❯ echo "Fix: Login Button." | bun commitlint --verbose
⧗   --- input ---
Fix: Login Button.
✖   subject must not be sentence-case [subject-case]
✖   subject may not end with full stop [subject-full-stop]
✖   type must be lower-case [type-case]
✖   type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]

✖   found 4 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
1 commands10 lines

Four: capitalised subject, trailing period, capitalised type, type not in the list. The list from the table earlier is right there in the type-enum rule.

A message that follows the convention comes back clean.

terminal
❯ echo "fix(auth): keep the session alive across a token refresh" | bun commitlint --verbose
⧗   --- input ---
fix(auth): keep the session alive across a token refresh
✔   found 0 problems, 0 warnings
1 commands4 lines

You can try it here before installing commitlint. It is the rules that look at the first line, ported to run in the browser, and the output is what commitlint prints in a terminal.

commitlintinteractive

Edit the message and it is checked as you type. The buttons load the three messages above.

typeFixscope—!—subjectLogin Button.
--- input ---
Fix: Login Button.
subject must not be sentence-case [subject-case]
subject may not end with full stop [subject-full-stop]
type must be lower-case [type-case]
type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]
found 4 problems, 0 warnings

4 problems. The name in brackets is the rule

The list of types sits in the type-enum rule as it is. A trailing space is caught too

Checking at commit time with lefthook

On its own, commitlint only runs when you pipe something into it. Wired to Git's commit-msg hook, it runs on every git commit. lefthook manages the hooks: a single Go binary, installed through mise.

bash
mise use lefthook

The configuration goes in lefthook.yml: the hook's name, and the command to run in it.

lefthook.ymlyaml
commit-msg:
  jobs:
    - run: bun commitlint --edit {1}

The commit-msg hook receives the path of a temporary file holding the message as its argument. lefthook expands that into {1}, and commitlint's --edit reads the file and checks it.

lefthook install writes the configuration into .git/hooks/.

terminal
❯ lefthook install
sync hooks: ✔️(commit-msg)
❯ ls .git/hooks
commit-msg
prepare-commit-msg
2 commands5 lines

.git/hooks/commit-msg is a short shell script that only calls lefthook; the substance stays in lefthook.yml. .git/ is not part of a clone, so whoever clones the repository runs lefthook install once themselves.

Now git commit enforces the convention. Try committing with a message that does not follow it.

terminal
❯ git commit -m "add lefthook"
╭───────────────────────────────────────╮
│ lefthook  v2.1.14   hook:  commit-msg │
╰───────────────────────────────────────╯
┃  bun commitlint --edit {1} ❯
⧗   --- input ---
add lefthook
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint


exit status 1
  ────────────────────────────────────
summary: (done in 1.16 seconds)
✗ bun commitlint --edit {1} (1.16 seconds)
1 commands18 lines

lefthook draws a frame naming the hook, commitlint's findings follow, and the job's result closes it with a ✗. No commit is created. Fix the message and try again.

terminal
❯ git commit -m "build(git): check commit messages with commitlint through lefthook"
╭───────────────────────────────────────╮
│ lefthook  v2.1.14   hook:  commit-msg │
╰───────────────────────────────────────╯
┃  bun commitlint --edit {1} ❯

  ────────────────────────────────────
summary: (done in 1.21 seconds)
✓ bun commitlint --edit {1} (1.21 seconds)
[main 9568ccc] build(git): check commit messages with commitlint through lefthook
 6 files changed, 216 insertions(+), 1 deletion(-)
 create mode 100644 .gitignore
 create mode 100644 bun.lock
 create mode 100644 commitlint.config.ts
 create mode 100644 lefthook.yml
 create mode 100644 mise.toml
1 commands16 lines

This time it goes through. The bun.lock, commitlint.config.ts, lefthook.yml and mise.toml in the list are exactly what this article has been building in that repository.

A hook is not the last line of defence

A hook runs on the developer's machine, and there are two ways around it.

The first is --no-verify, a standard Git option that skips the hooks.

terminal
❯ git commit --no-verify -m "wip"
[main 2d2ac02] wip
 1 file changed, 1 insertion(+)
1 commands3 lines

The same shape of message that was rejected a moment ago went straight in. It is an escape hatch for emergencies, and being an escape hatch, it gets used.

The second is committing with something other than Git. This blog's repository is managed with Jujutsu. jj works on the Git repository directly, but it does not run Git hooks. Open the repository lefthook was just set up in with jj as well, and commit with the same message.

terminal
❯ jj git init --colocate
Done importing changes from the underlying Git repo.
Initialized repo in "."
❯ jj commit -m "add lefthook"
Working copy  (@) now at: mxplotnk ee678aab (empty) (no description set)
Parent commit (@-)      : xorxynkl b4f0beae add lefthook
2 commands6 lines

Committed, with nothing checked. .git/hooks/commit-msg is still there, so from Git's side the setup is intact — but Git is not what is creating the commit, so there is no point at which a hook would be called.

That is why this repository does not use lefthook. Instead of checking locally, it checks in CI.

Checking in CI

A GitHub Actions workflow runs commitlint over the pull request title and every commit in the pull request. Unlike a hook, it does not matter which tool made the commit or whether --no-verify was passed. It runs somewhere every change must pass through before it merges, which makes it the real line of defence.

.github/workflows/conventional-commits.ymlyaml
on:
  pull_request_target:
    types: [opened, edited, reopened, synchronize]

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: oven-sh/setup-bun@v2
      - run: bun add -d @commitlint/cli @commitlint/config-conventional
      - run: |
          echo 'export default { extends: ["@commitlint/config-conventional"] }' \
            > commitlint.config.ts

      - name: Validate the pull request title
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: printf '%s\n' "$TITLE" | bun commitlint --verbose

      - name: Validate the pull request commits
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        run: |
          while read -r message; do
            printf '%s' "$message" | base64 -d | bun commitlint --verbose
          done < <(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \
            --jq '.[].commit.message | @base64')

This is an excerpt of the real workflow. What it does is the same bun commitlint this article has been typing at a terminal, with the input swapped for the pull request title and the commit messages fetched through gh api. A commit message spans several lines once it has a body, so each one is folded to a single line with @base64 for read to loop over, and unfolded just before the check.

The title is checked because this repository merges with squash merge. Under squash merge, the message of the commit that lands on main is the pull request title, so checking the individual commits alone would not protect the history of main.

The real workflow goes on to post commitlint's output on the pull request as a comment when the check fails, and to rewrite that comment to a ✅ once it passes. The whole file is in the repository.

Summary

  • Conventional Commits puts the first line of a commit in the shape type(scope): summary
  • feat and fix are defined by the spec and map to SemVer's minor and patch; ! or BREAKING CHANGE: means major
  • With the shape fixed, git log --grep filters the history, and the CHANGELOG and the next version fall out of it automatically
  • commitlint checks a message; wired to lefthook's commit-msg hook, it runs on every git commit
  • A hook can be skipped with --no-verify or by a non-Git tool such as jj. Checking the pull request title and commits in CI is the real line of defence

The convention itself is nothing more than a one-line format. What matters is that a machine can read it. From the moment the shape is consistent, the history stops being something only people read and becomes data that tools can use.

◢ RELATED DISPATCHES — Continue Exploring