git-cliff
git-cliff generates beautiful, customizable changelogs from git history using conventional commits and regex-powered custom parsers. The changelog body is a Tera template (similar to Jinja2/Django templates).
Core Concepts
- Config file:
cliff.tomlin the project root (or~/.config/git-cliff/cliff.tomlglobally) - Template engine: Tera —
{{ variable }},{% for %},{% if %}, filters like| upper_first - Conventional commits:
<type>[scope]: <description>— types includefeat,fix,docs,refactor,perf,test,chore,style,build,ci - Breaking changes:
feat!:/fix!:suffix orBREAKING CHANGE:footer → SemVer major bump feat:→ minor bump ·fix:→ patch bump
Quickstart
git-cliff --init # scaffold cliff.toml in the current directory
git-cliff -o CHANGELOG.md # generate full changelog from all history
CLI Reference
Boolean flags
| Flag | Description |
|---|---|
-u, --unreleased |
Only commits not yet associated with a tag |
-l, --latest |
Commits from the latest tag to HEAD |
--current |
Commits belonging to the currently checked-out tag |
--topo-order |
Sort tags topologically instead of chronologically |
--bumped-version |
Print the auto-bumped version string only (no changelog) |
-x, --context |
Print the template context as JSON (great for debugging) |
--no-exec |
Disable external command execution in pre/postprocessors |
--use-branch-tags |
Only include tags reachable from the current branch |
--offline |
Disable network access (remote integrations) |
Options
| Option | Short | Description |
|---|---|---|
--config <PATH> |
-c |
Config file path (default: cliff.toml; env: GIT_CLIFF_CONFIG) |
--tag <TAG> |
-t |
Label unreleased commits as this version |
--output [<PATH>] |
-o |
Write to file (omit path → writes to CHANGELOG.md) |
--prepend <PATH> |
-p |
Prepend new entries to an existing changelog file |
--bump |
Auto-bump version: auto (default), major, minor, or patch |
|
--body <TEMPLATE> |
-b |
Override the body template inline |
--strip <PART> |
-s |
Strip header, footer, or all from output |
--sort <SORT> |
Commit sort inside sections: oldest (default) or newest |
|
--include-path <PATTERN> |
Only include commits that touch these paths (glob) | |
--exclude-path <PATTERN> |
Exclude commits touching these paths (glob) | |
--tag-pattern <PATTERN> |
Regex for matching git tags | |
--skip-tags <PATTERN> |
Regex — skip these tags entirely | |
--ignore-tags <PATTERN> |
Ignore these tags (their commits roll into the next release) | |
--with-commit <MSG> |
Inject a synthetic commit message | |
--skip-commit <SHA> |
Skip a specific commit by SHA | |
--workdir <PATH> |
-w |
Set the working directory |
--repository <PATH> |
-r |
Set the git repository path |
[RANGE] |
Git commit range, e.g. v1.0.0..HEAD |
Remote Integration
git-cliff --github-token $GITHUB_TOKEN --github-repo owner/repo
git-cliff --gitlab-token $GITLAB_TOKEN --gitlab-repo owner/repo
# Also: --gitea-*, --bitbucket-*, --azure-devops-*
cliff.toml Schema
[changelog]
header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n"
body = """
{% if version %}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}
## [unreleased]
{% endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}
{% endfor %}
{% endfor %}
"""
footer = "<!-- generated by git-cliff -->"
trim = true # strip leading/trailing whitespace from rendered body
# render_always = false # render body even when there are no releases
# output = "CHANGELOG.md" # set default output path in config
postprocessors = [
# Regex replace across the final output string:
# { pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://github.com/owner/repo/issues/${1}))" }
]
[git]
conventional_commits = true # parse <type>[scope]: <desc> format
filter_unconventional = true # exclude non-conventional commits
# require_conventional = false # fail if any included commit is non-conventional
# split_commits = false # treat each commit body line as its own commit
commit_preprocessors = [
# Mutate commit messages before parsing:
# { pattern = " +", replace = " " } # collapse multiple spaces
# { pattern = "Merge pull request #([0-9]+) from [^ ]+", replace = "PR #${1}:" }
]
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^style", group = "Styling" },
{ message = "^test", group = "Testing" },
{ message = "^chore|^ci|^build", group = "Miscellaneous Tasks" },
{ message = "^revert", skip = true },
# { body = ".*security", group = "Security" },
# { footer = "^changelog: ?ignore", skip = true },
# { sha = "abc1234", skip = true },
]
protect_breaking_commits = false # never skip breaking changes, even if a parser would
filter_commits = false # if true, drop commits not matched by any parser
# fail_on_unmatched_commit = false
tag_pattern = "v[0-9].*" # regex for recognizing version tags
# skip_tags = "v0.1.0-beta.1"
# ignore_tags = "-rc[0-9]+$" # roll RC commits into the next full release
topo_order = false # topological tag ordering
sort_commits = "oldest" # oldest | newest
link_parsers = [
# Extract issue links from commit messages:
# { pattern = "#(\\d+)", href = "https://github.com/owner/repo/issues/$1" },
]
# limit_commits = 100
# include_paths = ["src/", "lib/**"]
# exclude_paths = ["vendor/"]
Tera Template Context
The body template receives one release object per tag. header and footer receive the full releases array.
Release fields
| Field | Type | Description |
|---|---|---|
version |
string | Tag name, e.g. "v1.2.0" (null for unreleased) |
message |
string | Annotated tag message |
timestamp |
int | Unix timestamp of the release commit |
previous |
object | { version } of the prior release |
commits |
array | Commit objects (see below) |
commit_id |
string | SHA of the release commit |
statistics |
object | commit_count, commits_timespan, conventional_commit_count, links[], days_passed_since_last_release |
Commit fields
| Field | Type | Description |
|---|---|---|
id |
string | Full commit SHA |
message |
string | Commit description (after type/scope parsing) |
group |
string | Set by commit_parsers |
scope |
string | Conventional commit scope |
breaking |
bool | true if breaking change |
breaking_description |
string | Explanation from BREAKING CHANGE: footer |
body |
string | Full commit body text |
footers |
array | { token, separator, value, breaking } |
conventional |
bool | Was parsed as conventional? |
merge_commit |
bool | Is a merge commit? |
author |
object | { name, email, timestamp } |
committer |
object | { name, email, timestamp } |
links |
array | { text, href } from link_parsers |
remote |
object | { username, pr_title, pr_number, pr_labels, is_first_contributor } |
Useful Tera patterns
{# Group by group label and list scoped commits #}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits | sort(attribute="message") %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }}
{% endfor %}
{% endfor %}
{# Highlight breaking changes separately #}
{% set breaking = commits | filter(attribute="breaking", value=true) %}
{% if breaking | length > 0 %}
### ⚠️ Breaking Changes
{% for commit in breaking %}
- {{ commit.breaking_description | default(value=commit.message) | upper_first }}
{% endfor %}
{% endif %}
{# Comparison links in footer using previous version #}
{% for release in releases %}
{% if release.previous.version %}
[{{ release.version }}]: https://github.com/owner/repo/compare/{{ release.previous.version }}..{{ release.version }}
{% endif %}
{% endfor %}
{# Trim version prefix for display #}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
Key Tera built-ins: upper_first, lower, upper, trim, trim_start_matches(pat=...), replace(from=..., to=...), indent(prefix=...), group_by(attribute=...), filter(attribute=..., value=...), sort(attribute=...), unique, length, date(format=...), default(value=...), join(sep=...)
Common Workflows
1. Initialize and generate full changelog
git-cliff --init # creates cliff.toml
git-cliff -o CHANGELOG.md # writes complete changelog
2. Preview unreleased changes
git-cliff --unreleased
git-cliff --unreleased --tag 2.0.0 # label what the next release will be
3. Prepend unreleased to existing changelog (typical release flow)
git-cliff --unreleased --tag v1.3.0 --prepend CHANGELOG.md
# Note: --prepend and -o with the same path are incompatible
4. Auto-bump + prepend (fully automated release)
VERSION=$(git-cliff --bumped-version)
git-cliff --unreleased --tag "$VERSION" --prepend CHANGELOG.md
git add CHANGELOG.md && git commit -m "chore(release): $VERSION"
git tag "$VERSION" && git push --follow-tags
5. Generate for a specific range
git-cliff v1.0.0..HEAD
git-cliff v1.0.0..v2.0.0
git-cliff HEAD~10.. # last 10 commits only
6. Latest release notes only
git-cliff --latest -o RELEASE_NOTES.md
git-cliff --latest --strip all # body only, no header/footer
7. Debug your template
git-cliff --context # print full JSON context
git-cliff --context | jq '.[0].commits[] | {group, scope, message}'
8. Monorepo per-package changelogs
git-cliff --include-path "packages/my-lib/**" \
--tag-pattern "my-lib-v[0-9].*" \
--tag my-lib-v1.0.0 \
-o packages/my-lib/CHANGELOG.md
9. GitHub integration (PR titles, authors, labels)
GITHUB_TOKEN=xxx git-cliff --github-repo owner/repo -o CHANGELOG.md
10. CI/CD (GitHub Actions example)
- name: Generate changelog
run: |
VERSION=$(git-cliff --bumped-version)
git-cliff --unreleased --tag "$VERSION" --prepend CHANGELOG.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Environment Variables
Config field overrides use the pattern GIT_CLIFF__<SECTION>__<FIELD> (double underscore):
export GIT_CLIFF__CHANGELOG__FOOTER="<!-- generated by git-cliff -->"
export GIT_CLIFF__GIT__IGNORE_TAGS="v[0-9]+\.[0-9]+\.[0-9]+-rc[0-9]+"
export GIT_CLIFF_CONFIG=./custom-cliff.toml
export GIT_CLIFF_OUTPUT=CHANGELOG.md
export GITHUB_TOKEN=ghp_xxx # for GitHub remote integration
Tips & Patterns
- Debug first: Run
git-cliff --context | jqto see exactly what data your template receives before writing the template. protect_breaking_commits = true: Ensures breaking changes always appear even if a skip rule would otherwise hide them.filter_commits = true: Drop any commit not matched bycommit_parsers— great for noise-free changelogs.- Opt-out per commit: Add
{ footer = "^changelog: ?ignore", skip = true }to let authors mark individual commits as "don't include". - Monorepo: Use
--include-pathper package with per-package tag patterns. Version tags can be prefixed:my-pkg-v1.0.0. - PR labels as groups: With GitHub remote integration, match
remote.pr_labelsincommit_parsersto group by PR label categories. split_commits = true: Treats each line of a commit body as a separate entry — useful when squash merges pack multiple changes into one commit message.ignore_tagsvsskip_tags:ignore_tagsrolls the commits into the next release;skip_tagsdrops them entirely.- Postprocessors: Run regex replacements on the final rendered changelog string — useful for linkifying issue numbers globally.
Reference
- Full docs: https://git-cliff.org/docs/
- Configuration: https://git-cliff.org/docs/configuration
- Templating: https://git-cliff.org/docs/category/templating
- Template examples: https://git-cliff.org/docs/templating/examples
- CLI args: https://git-cliff.org/docs/usage/args