107 commits. Conventional commits impecáveis desde o primeiro dia. Feat, fix, refactor, chore — tudo perfeitamente etiquetado. E o CHANGELOG? Vazio. Inexistente. Um arquivo que “já vou escrever amanhã” durante dois meses.

Se isso soa familiar, você não está sozinho. Escrever um changelog manualmente é um saco de categoria olímpica. Não é que seja difícil — é que é tedioso, repetitivo, e sempre há algo mais urgente para fazer. E justamente por isso existe o git-cliff.

O que é git-cliff (em 30 segundos)

É um gerador de changelogs escrito em Rust que lê seus commits do git, os processa seguindo conventional commits, e produz um CHANGELOG.md agrupado por versão e tipo. Sem dependências estranhas, sem plugins, sem magia negra. Um binário, um arquivo de configuração, e pronto.

Em outras palavras: você dá seus commits e ele te devolve o arquivo que você vem adiando há meses.

1
2
brew install git-cliff
git cliff --output CHANGELOG.md

Essas duas linhas são literalmente tudo que você precisa para começar. Se seus commits seguem a convenção tipo: descrição, o git-cliff os entende sem configuração adicional.

O caso real: changelog retroativo para 8 versões

Em Tokamak (meu app de menu bar para monitorar a cota do Claude) eu tinha exatamente esse problema: 107 commits perfeitos e um CHANGELOG em branco. O app já estava na v1.3.0 mas só existia uma tag v0.1 do começo dos tempos.

O plano era simples:

Passo 1: Criar tags retroativas nos commits de cada versão.

1
2
3
4
5
git tag v0.2.0 32950f4   # Dashboard, biblioteca, achievements v1
git tag v0.3.0 9c56985   # Rename para Tokamak, 6 idiomas
git tag v1.0.0 a283490   # App Store: sandbox, privacy manifest
git tag v1.3.0 6248bac   # HEAD: multi-provider, fetch pipeline
# ... e assim por diante

Passo 2: Executar git-cliff.

1
git cliff --output CHANGELOG.md

Resultado: um CHANGELOG.md completo com 8 versões, cada uma com suas features, bug fixes, refactors e chores agrupados. 189 linhas. 30 segundos.

A configuração: cliff.toml

Git-cliff usa um arquivo TOML com duas seções principais: [changelog] para o formato de saída e [git] para como interpretar os commits.

Esta é a configuração que uso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[changelog]
header = """
# Changelog

All notable changes to Tokamak are 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 | striptags | trim | upper_first }}
{% for commit in commits -%}
- {{ commit.message | split(pat="\n") | first | trim }}
{% endfor -%}
{% endfor %}
"""
trim = true

[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
  { message = "^feat", group = "Features" },
  { message = "^fix", group = "Bug Fixes" },
  { message = "^refactor", group = "Refactor" },
  { message = "^test", group = "Tests" },
  { message = "^docs", group = "Documentation" },
  { message = "^chore", group = "Chores" },
]
tag_pattern = "v[0-9].*"
sort_commits = "oldest"

Três coisas a notar:

  1. trim_start_matches(pat="v") — As tags são v1.3.0 mas no changelog quero 1.3.0. Este filtro do Tera faz isso.

  2. filter_unconventional = true — Descarta commits que não seguem a convenção. Se você tem um repo com commits antigos tipo “fixed stuff”, ponha como false e adicione um parser catch-all: { message = ".*", group = "Other" }.

  3. sort_commits = "oldest" — Os commits dentro de cada grupo vão em ordem cronológica. Para ordem inversa: "newest".

Templates: a linguagem que você não sabia que precisava

O body usa Tera, um motor de templates inspirado no Jinja2. A curva de aprendizado não é zero, mas também não é Haskell. Alguns filtros úteis:

FiltroO que fazExemplo
trim_start_matchesRemove prefixov1.01.0
upper_firstCapitalizafeaturesFeatures
split + firstPrimeira linhaIgnora corpo do commit
dateFormata data%Y-%m-%d
group_byAgrupaPor tipo de commit

O truque mais útil: commit.message | split(pat="\n") | first extrai apenas a primeira linha da mensagem do commit. Se você escreve corpos longos em seus commits (e deveria), isso evita que o changelog se torne um romance.

Pegadinha: o whitespace do Tera

Este foi o que me custou mais tempo. Os templates do Tera são sensíveis ao whitespace. Uma quebra de linha a mais ou a menos no template e seu changelog sai com espaços estranhos ou com seções grudadas.

As chaves:

  • {%- (com hífen): elimina o whitespace antes da tag
  • -%} (com hífen): elimina o whitespace depois da tag
  • trim = true em [changelog]: elimina whitespace de cada linha gerada

O problema é que trim = true também remove as quebras de linha que você quer — como a separação entre versões. A solução é deixar um {% endfor %} sem hífen no final do loop exterior, para que o Tera emita a quebra extra.

Não é exatamente intuitivo. Mas uma vez que funciona, funciona.

Casos de uso que não são óbvios

Git-cliff não é apenas “gera um CHANGELOG e pronto”. Há usos que valem a pena conhecer:

Gerar apenas a última versão

1
git cliff --latest

Perfeito para release notes. Em vez do changelog completo, só te dá a seção da versão mais recente. Ideal para colar na descrição de um release do GitHub ou Gitea.

Preview sem tag

1
git cliff --tag v2.0.0 --unreleased

Gera a seção de uma versão que você ainda não taggeou. Útil para revisar o que vai incluir seu próximo release antes de criá-lo.

Bump automático

1
git cliff --bumped-version

Git-cliff analisa os commits desde a última tag e te diz qual versão deveria ser a próxima segundo semver: se só há fix, sobe o patch; se há feat, sobe o minor; se há breaking changes, sobe o major.

Changelog por range

1
git cliff v1.0.0..v1.2.0

Apenas os commits entre duas tags. Útil para gerar as notas de um hotfix ou para auditar o que mudou entre duas versões específicas.

Monorepos

Se seu repo tem vários projetos, o git-cliff suporta --include-path para filtrar commits que afetam um caminho específico:

1
git cliff --include-path "api/**"

Cada subprojeto pode ter seu próprio cliff.toml e seu próprio changelog.

Integrar no seu fluxo (sem esquecer)

O maior risco do git-cliff não é técnico — é humano. Você vai esquecer de executá-lo. Eu sei porque já aconteceu comigo.

A solução mais pragmática: integrá-lo como dependência do passo que você vai se lembrar de fazer. No meu caso, o Makefile:

1
2
3
4
5
changelog:  ## Gerar CHANGELOG.md desde commits
	git cliff --output CHANGELOG.md

archive: generate changelog  ## Criar xcarchive (Release)
	xcodebuild archive ...

Agora make archive regenera o changelog automaticamente antes de compilar para release. Não preciso me lembrar de nada — quando vou publicar, o changelog se atualiza sozinho.

Outras opções razoáveis:

  • GitHub Actions: git cliff --latest no workflow de release para gerar as notas automaticamente.
  • Pre-push hook: regenerar antes de cada push. Mais agressivo, mais barulhento.
  • Post-tag hook: regenerar ao criar uma tag. O mais lógico semanticamente, mas git não tem um hook nativo para tags (precisaria de um wrapper).

Minha recomendação: amarre o changelog ao passo de release. É onde realmente importa. Um changelog desatualizado no main entre releases não importa para ninguém.

Conventional commits: o requisito prévio

Tudo isso assume que seus commits seguem a convenção tipo(scope): descrição:

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

Se seus commits são do tipo “wip”, “stuff”, “asdf” e “fix things maybe”, o git-cliff não vai fazer milagres. Lixo entra, lixo sai.

Mas a boa notícia é que você não precisa de ferramentas para fazer conventional commits. É apenas uma convenção de nomenclatura. Isso sim, se você quer validação, commitlint ou um pre-commit hook simples garantem que ninguém (incluindo você às 3 da manhã) pule o formato.

Por que git-cliff e não outra coisa

Há alternativas: standard-version, semantic-release, conventional-changelog, release-please. Todas funcionam. Mas o git-cliff tem três vantagens que para mim são decisivas:

  1. É um binário. brew install e funciona. Sem Node, sem Python, sem ecossistema. Em um projeto Swift como o meu, não quero adicionar package.json só pelo changelog.

  2. É rápido. 120 milissegundos para 10.000 commits (segundo benchmarks reais). As alternativas baseadas em Node levam 30 segundos para o mesmo repo.

  3. O template é seu. Se quer emojis, você põe. Se quer links para issues, você põe. Se quer o changelog em YAML, pode. O motor Tera te dá controle total sobre o output.

O resultado

De 0 a changelog completo em menos de 5 minutos:

## 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 versões, 107 commits, agrupados por tipo, com datas e referências a issues. Sem escrever uma linha sequer manualmente.

Da próxima vez que alguém perguntar “o que mudou na última versão?”, você não vai ter que vasculhar entre commits nem improvisar de memória. Vai ter um arquivo. Gerado automaticamente. Que se atualiza sozinho quando você publica.

E se algum dia mudar de opinião sobre o formato, muda o template. Os commits — seus commits de sempre — são a fonte da verdade. O git-cliff só os apresenta de forma bonita.