107 个提交。从第一天开始就是完美的约定式提交。Featfixrefactorchore — 所有内容都完美标记。那么 CHANGELOG 呢?空的。不存在。一个"明天再写"的文件,已经拖了两个月。

如果这听起来很熟悉,你不是一个人。手动编写变更日志是奥林匹克级别的苦差事。不是说它难 — 而是它乏味、重复,总有更紧急的事情要做。这就是为什么 git-cliff 存在的原因。

什么是 git-cliff(30 秒版本)

这是一个用 Rust 编写的变更日志生成器,它读取你的 git 提交,根据约定式提交解析它们,然后输出按版本和类型分组的 CHANGELOG.md。没有奇怪的依赖,没有插件,没有黑魔法。一个二进制文件,一个配置文件,就完了。

简单来说:你给它你的提交,它返回你拖延了几个月的文件。

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

这两行字面上就是开始所需的全部。如果你的提交遵循 类型: 描述 约定,git-cliff 无需额外配置就能理解它们。

真实案例:8 个版本的回溯性变更日志

Tokamak(我的监控 Claude 配额的菜单栏应用)中,我正好遇到了这个问题:107 个完美提交和一个空白的 CHANGELOG。应用已经是 v1.3.0 版本,但只有一个开始时的 v0.1 标签。

计划很简单:

步骤 1:在每个版本的提交上创建回溯性标签。

1
2
3
4
5
git tag v0.2.0 32950f4   # Dashboard, biblioteca, achievements v1
git tag v0.3.0 9c56985   # Rename a 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
# ... 以此类推

步骤 2:运行 git-cliff。

1
git cliff --output CHANGELOG.md

结果:一个包含 8 个版本的完整 CHANGELOG.md,每个版本都有其功能、bug 修复、重构和杂务分组。189 行。30 秒。

配置:cliff.toml

Git-cliff 使用一个 TOML 文件,有两个主要部分:[changelog] 用于输出格式,[git] 用于如何解释提交。

这是我使用的配置:

 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"

需要注意三点:

  1. trim_start_matches(pat="v") — 标签是 v1.3.0,但在变更日志中我想要 1.3.0。这个 Tera 过滤器做到了这一点。

  2. filter_unconventional = true — 丢弃不遵循约定的提交。如果你有一个包含旧式提交(如"fixed stuff")的仓库,将其设置为 false 并添加一个全捕获解析器:{ message = ".*", group = "Other" }

  3. sort_commits = "oldest" — 每组内的提交按时间顺序排列。要反序:"newest"

模板:你不知道自己需要的语言

body 使用 Tera,一个受 Jinja2 启发的模板引擎。学习曲线不是零,但也不是 Haskell。一些有用的过滤器:

过滤器功能示例
trim_start_matches删除前缀v1.01.0
upper_first首字母大写featuresFeatures
split + first第一行忽略提交正文
date格式化日期%Y-%m-%d
group_by分组按提交类型

最有用的技巧:commit.message | split(pat="\n") | first 只提取提交消息的第一行。如果你在提交中写长正文(你应该这样做),这可以防止变更日志变成小说。

陷阱:Tera 的空白字符

这是最耗费我时间的。Tera 模板对空白字符敏感。模板中多一个或少一个换行符,你的变更日志就会出现奇怪的空隙或部分粘在一起。

关键点:

  • {%-(带破折号):删除标签之前的空白字符
  • -%}(带破折号):删除标签之后的空白字符
  • [changelog] 中的 trim = true:删除每个生成行的空白字符

问题是 trim = true 也会吃掉你确实想要的换行符 — 比如版本之间的分隔。解决方案是在外层循环结尾留一个没有破折号的 {% endfor %},让 Tera 输出额外的换行符。

不是很直观。但一旦工作起来,就一直工作。

不明显的用例

Git-cliff 不只是"生成一个 CHANGELOG 就完了"。有一些值得了解的用途:

只生成最新版本

1
git cliff --latest

非常适合发布说明。不是完整的变更日志,只给你最新版本的部分。非常适合粘贴到 GitHub 或 Gitea 发布描述中。

无标签预览

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

生成你还没有标记的版本部分。在创建之前预览下一个发布将包含什么内容很有用。

自动版本号递增

1
git cliff --bumped-version

Git-cliff 分析自上次标签以来的提交,并根据semver告诉你下一个版本应该是什么:如果只有fix,递增patch;如果有feat,递增minor;如果有破坏性变更,递增major

范围变更日志

1
git cliff v1.0.0..v1.2.0

只有两个标签之间的提交。对生成热修复说明或审计两个特定版本之间的变更很有用。

单仓库多项目

如果你的仓库有多个项目,git-cliff 支持 --include-path 来过滤影响特定路径的提交:

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

每个子项目可以有自己的 cliff.toml 和自己的变更日志。

集成到你的工作流程(不要忘记)

Git-cliff 最大的风险不是技术性的 — 是人为的。你会忘记运行它。我知道因为这发生在我身上了。

最实用的解决方案:将其作为你肯定会记得做的步骤的依赖集成。在我的情况下,Makefile

1
2
3
4
5
changelog:  ## 从提交生成 CHANGELOG.md
	git cliff --output CHANGELOG.md

archive: generate changelog  ## 创建 xcarchive(发布)
	xcodebuild archive ...

现在 make archive 在编译发布版之前自动重新生成变更日志。我不需要记住任何事情 — 当我要发布时,变更日志自己更新。

其他合理选项:

  • GitHub Actions:在发布工作流程中 git cliff --latest 自动生成说明。
  • Pre-push hook:每次推送前重新生成。更激进,更嘈杂。
  • Post-tag hook:创建标签时重新生成。语义上最合理,但 git 没有标签的原生钩子(需要包装器)。

我的建议:将变更日志绑定到发布步骤。那才是真正重要的地方。发布之间 main 分支上过时的变更日志没人在乎。

约定式提交:先决条件

所有这些都假设你的提交遵循 类型(范围): 描述 约定:

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

如果你的提交是"wip"、“stuff”、“asdf"和"fix things maybe"类型,git-cliff 不会创造奇迹。垃圾进,垃圾出。

但好消息是你不需要工具来做约定式提交。这只是一个命名约定。不过,如果你想要验证,commitlint或简单的pre-commit hook可以确保没有人(包括凌晨 3 点的你)跳过格式。

为什么是 git-cliff 而不是其他

有替代方案:standard-versionsemantic-releaseconventional-changelogrelease-please。它们都有效。但 git-cliff 有三个对我来说决定性的优势:

  1. 它是一个二进制文件brew install 就能工作。没有 Node,没有 Python,没有生态系统。在像我的 Swift 项目中,我不想只为了变更日志添加 package.json

  2. 它很快。10,000 个提交 120 毫秒(根据真实基准测试)。基于 Node 的替代方案对相同仓库需要 30 秒。

  3. 模板是你的。如果你想要表情符号,就加上。如果你想要 issue 链接,就加上。如果你想要 YAML 格式的变更日志,可以。Tera 引擎给你对输出的完全控制。

结果

从 0 到完整变更日志,不到 5 分钟:

## 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 个版本,107 个提交,按类型分组,带有日期和 issue 引用。没有手写一行。

下次有人问你"最新版本改变了什么?",你不需要在提交中搜索或凭记忆即兴发挥。你会有一个文件。自动生成的。在你发布时自己更新。

如果哪天你改变了对格式的想法,就改变模板。提交 — 你一如既往的提交 — 是真相的来源。git-cliff 只是把它们呈现得漂亮。