107 commits. Impeccable conventional commits from day one. Feat, fix, refactor, chore — everything perfectly labeled. And the CHANGELOG? Empty. Non-existent. A file that “I’ll write tomorrow” for two months straight.
If this sounds familiar, you’re not alone. Writing a changelog by hand is an Olympic-level pain in the ass. It’s not that it’s difficult — it’s just tedious, repetitive, and there’s always something more urgent to do. And that’s exactly why git-cliff exists.
What is git-cliff (in 30 seconds)
It’s a changelog generator written in Rust that reads your git commits, parses them according to conventional commits, and spits out a CHANGELOG.md grouped by version and type. No weird dependencies, no plugins, no black magic. One binary, one config file, and you’re done.
In plain language: you give it your commits and it returns the file you’ve been postponing for months.
| |
Those two lines are literally all you need to get started. If your commits follow the type: description convention, git-cliff understands them without any additional configuration.
The real case: retroactive changelog for 8 versions
In Tokamak (my menu bar app for monitoring Claude’s quota) I had exactly that problem: 107 perfect commits and a blank CHANGELOG. The app was already at v1.3.0 but only a v0.1 tag existed from the dawn of time.
The plan was simple:
Step 1: Create retroactive tags on the commits for each version.
| |
Step 2: Run git-cliff.
| |
Result: a complete CHANGELOG.md with 8 versions, each one with its features, bug fixes, refactors and chores grouped. 189 lines. 30 seconds.
The configuration: cliff.toml
Git-cliff uses a TOML file with two main sections: [changelog] for output formatting and [git] for how to interpret commits.
This is the configuration I use:
| |
Three things to notice:
trim_start_matches(pat="v")— Tags arev1.3.0but in the changelog I want1.3.0. This Tera filter does it.filter_unconventional = true— Discards commits that don’t follow the convention. If you have a repo with old commits like “fixed stuff”, set it tofalseand add a catch-all parser:{ message = ".*", group = "Other" }.sort_commits = "oldest"— Commits within each group are in chronological order. For reverse order:"newest".
Templates: the language you didn’t know you needed
The body uses Tera, a template engine inspired by Jinja2. The learning curve isn’t zero, but it’s not Haskell either. Some useful filters:
| Filter | What it does | Example |
|---|---|---|
trim_start_matches | Removes prefix | v1.0 → 1.0 |
upper_first | Capitalizes | features → Features |
split + first | First line | Ignores commit body |
date | Formats date | %Y-%m-%d |
group_by | Groups | By commit type |
The most useful trick: commit.message | split(pat="\n") | first extracts only the first line of the commit message. If you write long bodies in your commits (and you should), this prevents the changelog from becoming a novel.
Gotcha: Tera whitespace
This is the one that took me the longest. Tera templates are sensitive to whitespace. One extra newline or one too few in the template and your changelog comes out with weird gaps or sections stuck together.
The keys:
{%-(with dash): removes whitespace before the tag-%}(with dash): removes whitespace after the tagtrim = truein[changelog]: removes whitespace from each generated line
The problem is that trim = true also eats the newlines you do want — like the separation between versions. The solution is to leave a {% endfor %} without a dash at the end of the outer loop, so Tera emits the extra break.
Not exactly intuitive. But once it works, it works.
Use cases that aren’t obvious
Git-cliff isn’t just “generate a CHANGELOG and done”. There are uses worth knowing about:
Generate only the latest version
| |
Perfect for release notes. Instead of the complete changelog, it only gives you the section for the most recent version. Ideal for pasting into a GitHub or Gitea release description.
Preview without tag
| |
Generates the section for a version you haven’t tagged yet. Useful for reviewing what your next release will include before creating it.
Automatic bump
| |
Git-cliff analyzes the commits since the last tag and tells you what the next version should be according to semver: if there are only fixes, it bumps the patch; if there are features, it bumps the minor; if there are breaking changes, it bumps the major.
Changelog by range
| |
Only commits between two tags. Useful for generating notes for a hotfix or for auditing what changed between two specific versions.
Monorepos
If your repo has multiple projects, git-cliff supports --include-path to filter commits that affect a specific path:
| |
Each subproject can have its own cliff.toml and its own changelog.
Integrating it into your workflow (without forgetting)
The biggest risk with git-cliff isn’t technical — it’s human. You’re going to forget to run it. I know because it happened to me.
The most pragmatic solution: integrate it as a dependency of the step you will remember to do. In my case, the Makefile:
| |
Now make archive automatically regenerates the changelog before compiling for release. I don’t have to remember anything — when I go to publish, the changelog updates itself.
Other reasonable options:
- GitHub Actions:
git cliff --latestin the release workflow to generate notes automatically. - Pre-push hook: regenerate before each push. More aggressive, more noisy.
- Post-tag hook: regenerate when creating a tag. The most semantically logical, but git doesn’t have a native hook for tags (you’d need a wrapper).
My recommendation: tie the changelog to the release step. That’s where it actually matters. An outdated changelog on main between releases doesn’t matter to anyone.
Conventional commits: the prerequisite
All of this assumes your commits follow the type(scope): description convention:
feat: add login with OAuth
fix: prevent crash on empty response
refactor: extract auth logic to separate module
chore: update dependencies
docs: add API reference
test: add edge cases for parser
If your commits are like “wip”, “stuff”, “asdf” and “fix things maybe”, git-cliff isn’t going to work miracles. Garbage in, garbage out.
But the good news is you don’t need tools to do conventional commits. It’s just a naming convention. That said, if you want validation, commitlint or a simple pre-commit hook ensures nobody (including you at 3AM) skips the format.
Why git-cliff and not something else
There are alternatives: standard-version, semantic-release, conventional-changelog, release-please. They all work. But git-cliff has three advantages that are decisive for me:
It’s a binary.
brew installand it works. No Node, no Python, no ecosystem. In a Swift project like mine, I don’t want to addpackage.jsonjust for the changelog.It’s fast. 120 milliseconds for 10,000 commits (according to real benchmarks). Node-based alternatives take 30 seconds for the same repo.
The template is yours. If you want emojis, you add them. If you want links to issues, you add them. If you want the changelog in YAML, you can. The Tera engine gives you total control over the output.
The result
From 0 to complete changelog in less than 5 minutes:
## 1.3.0 — 2026-02-22
### Features
- QuotaFetchStrategy pipeline with fallback for resilient quota fetching
- semi-automatic screenshot capture for App Store (8 locales × 3 scenarios)
### Bug Fixes
- attack phrase wrapping in exhausted view (TOK-115)
- resolve all notarization signing issues (TOK-93/94/95/96)
### Refactor
- QuotaProvider protocol for multi-provider support (TOK-53)
## 1.2.0 — 2026-02-22
...
8 versions, 107 commits, grouped by type, with dates and issue references. Without writing a single line by hand.
The next time someone asks you “what changed in the latest version?”, you won’t have to dig through commits or improvise from memory. You’ll have a file. Generated automatically. That updates itself when you publish.
And if you ever change your mind about the format, you change the template. The commits — your regular commits — are the source of truth. git-cliff just presents them nicely.