# Git Cliff

> Expert guide for git-cliff, a highly customizable changelog generator from git history. Invoke this skill whenever the user wants to generate, update, or customize a CHANGELOG.md file; auto-bump semantic versions; configure cliff.toml; write Tera templates for changelogs; work with conventional commits for changelog purposes; set up git-cliff in CI/CD pipelines; filter commits by path for monorepos; integrate GitHub/GitLab remote metadata into changelogs; debug changelog output; or run any git-cliff CLI command. Use even when the user just says "generate changelog", "update my CHANGELOG", "prep release notes", or "bump my version" — any changelog or release-notes workflow likely benefits from this skill.

- Skill: `kpatryk/git-cliff` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kpatryk/git-cliff`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kpatryk/git-cliff/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: kpatryk (https://skillmd.com/u/kpatryk)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kpatryk/git-cliff

---


# git-cliff

git-cliff generates beautiful, customizable changelogs from git history using [conventional commits](https://www.conventionalcommits.org) and regex-powered custom parsers. The changelog body is a [Tera](https://keats.github.io/tera/) template (similar to Jinja2/Django templates).

## Core Concepts

- **Config file**: `cliff.toml` in the project root (or `~/.config/git-cliff/cliff.toml` globally)
- **Template engine**: Tera — `{{ variable }}`, `{% for %}`, `{% if %}`, filters like `| upper_first`
- **Conventional commits**: `<type>[scope]: <description>` — types include `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `chore`, `style`, `build`, `ci`
- **Breaking changes**: `feat!:` / `fix!:` suffix or `BREAKING CHANGE:` footer → SemVer **major** bump
- **`feat:`** → minor bump · **`fix:`** → patch bump

## Quickstart

```bash
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
```bash
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

```toml
[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

```jinja2
{# 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
```bash
git-cliff --init           # creates cliff.toml
git-cliff -o CHANGELOG.md  # writes complete changelog
```

### 2. Preview unreleased changes
```bash
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)
```bash
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)
```bash
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
```bash
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
```bash
git-cliff --latest -o RELEASE_NOTES.md
git-cliff --latest --strip all   # body only, no header/footer
```

### 7. Debug your template
```bash
git-cliff --context                    # print full JSON context
git-cliff --context | jq '.[0].commits[] | {group, scope, message}'
```

### 8. Monorepo per-package changelogs
```bash
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)
```bash
GITHUB_TOKEN=xxx git-cliff --github-repo owner/repo -o CHANGELOG.md
```

### 10. CI/CD (GitHub Actions example)
```yaml
- 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):

```bash
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 | jq` to 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 by `commit_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-path` per 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_labels` in `commit_parsers` to 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_tags`** vs **`skip_tags`**: `ignore_tags` rolls the commits into the *next* release; `skip_tags` drops 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

