Commit Message 要解释意图,不只复述文件变化

0阅读9分钟

凌晨两点,你盯着 git log 发呆

想象一个场景:线上出了一个分类页数据错乱的 bug,你被叫起来排查。打开 git log,翻到三周前的提交记录——

commit a3f2b1c
Author: 某同事
Date:   Fri Jun 4 10:23:11 2026 +0800

    改了一下 merge 逻辑

你打开 diff,发现 merge.ts 里加了一整段本地文章优先合并的判断分支。但这条提交信息告诉你任何关于「为什么加这段逻辑」的信息了吗?没有。你只知道有人「改了一下」某个文件。你甚至不确定这到底是在修 bug 还是在加功能。

这个场景我太熟了。它不是个例,而是一种普遍存在的提交信息写作习惯——把 diff 翻译成一句废话。

这篇文章想讨论的是:好的 commit message 到底在传达什么,为什么「解释意图」比「复述变化」重要得多,以及具体怎么写。

为什么 diff 不够——提交信息的本质是索引

很多人觉得:代码变更已经全在 diff 里了,commit message 随便写个摘要就行。这个想法的问题在于,它混淆了「记录」和「索引」。

diff 是记录——它忠实地展示了每一行的增删改。但 diff 不是索引。当你在三个月后、面对几百个提交的项目历史排查问题时,你不可能逐个打开 diff 去看。你依赖的是 git log --oneline 里那一行文字,快速判断「这个提交跟当前问题有没有关系」。

这就是 commit message 的核心价值:它是提交历史的索引,帮助未来的自己或同事快速定位。一个好的索引需要回答三个问题:

  1. 改了什么——行为层面的变化,不是文件名层面的
  2. 为什么改——动机、背景、触发条件
  3. 影响范围——哪些模块、哪些用户场景会受影响

只写「修改 merge.ts」,相当于在一本书的目录里写「第 3 章」。它不是索引,它是噪音。

Thoughtbot 的工程实践文章里提过一个有用的检验标准:commit message 应该让你在不看 diff 的情况下,理解这次提交的目的和大致方向。1 如果你做不到,说明你的提交信息只是在复述文件变化。

案例拆解:三种常见的「无效提交信息」

案例一:文件名搬运工

// ❌ 坏:把文件名翻译成一句话
fix: 修改 merge.ts

这条信息唯一的价值是告诉你动了哪个文件。但它没有说明:改了什么行为?解决了什么问题?是修 bug 还是加功能?

// ✅ 好:描述行为和意图
fix(classify): 修复分类页本地文章被远端覆盖的问题

本地缓存的草稿文章在同步时被远端数据覆盖,导致用户丢失未发布的编辑内容。
调整合并优先级:本地有未发布草稿时,优先保留本地版本,远端数据作为增量合并。

区别一目了然。后者让你不看 diff 就知道:出了什么问题、影响了谁、怎么修的。

案例二:万能的「修复 bug」

// ❌ 坏:没有任何定位信息
fix: 修复了一个 bug

我见过比这更过分的——整个提交历史里全是 fix bugupdate codechanges。这类信息在 git log 里看起来就像一堆乱码。你无法区分哪个提交修了哪个问题,也无法用 git log --grep 搜索到它。

// ✅ 好:定位到具体的问题和场景
fix(auth): 修复会话过期后刷新 token 导致的无限重定向

当 access_token 过期但 refresh_token 仍有效时,401 拦截器会同时触发
token 刷新和登录页跳转,造成死循环。增加刷新中的请求队列挂起机制,
刷新成功后重放挂起请求,失败后再跳转登录页。

案例三:看似详细实则空洞

// ❌ 坏:写了很多字但没有实质信息
refactor: 优化了代码结构,提升了可读性和可维护性

这种提交信息看起来「很正式」,但它跟没写一样。「优化代码结构」是什么结构?「提升可读性」对谁而言?这类描述放在任何一个提交上都成立,所以它等于什么都没说。

// ✅ 好:说明具体做了什么重构、为什么
refactor(order): 将订单状态机从 switch-case 改为状态模式

原有 30+ 行的 switch-case 分布在 3 个服务方法中,新增状态时容易遗漏分支。
抽取 OrderStateMachine 类,每个状态对应一个 handler,新增状态只需添加新 handler。
行为不变,现有测试全部通过。

标题写作的核心原则

commit message 的标题(第一行)是最重要的部分。大多数时候,团队看 git log --oneline 只看标题。标题写不好,后面全白搭。

原则一:描述行为,不是文件名。 标题应该描述用户可见的行为变化、系统能力变化或风险修复。「修复分类页本地文章合并」比「修改 merge.ts」有价值十倍。

原则二:使用祈使语气。 英文用 fix 不用 fixed,中文可以直接描述动作。一个实用的检验公式:在标题前面加上「如果应用这个提交,将会……」,如果能读通,说明语气对了。

原则三:控制长度。 Git 的惯例是标题不超过 72 个字符(不是字节)。超过这个长度,在各种 Git 工具里都会被截断。如果你发现 72 个字符说不清楚,说明这个提交可能包含了太多变化,应该拆分。

原则四:不写废话。 不要在标题里加「紧急修复」「临时方案」「先这样」这类情绪词。这些信息如果重要,放到正文或关联的 issue 里。标题是给三个月后的你看的,不是给今天的 Slack 消息看的。

Conventional Commits:结构化你的提交信息

如果你的项目还没有采用 Conventional Commits,我强烈建议考虑。2 它不是一个沉重的流程,而是一套轻量的格式约定:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

核心 type 包括:

type用途示例
feat新功能feat(search): 支持按标签过滤搜索结果
fix修 bugfix(pay): 修复金额为 0 时重复扣款
refactor重构(不改变行为)refactor(order): 订单状态机改为状态模式
docs文档变更docs(api): 补充 token 刷新接口说明
style代码格式(不影响逻辑)style: 统一 async 函数错误处理格式
perf性能优化perf(list): 虚拟滚动优化万级列表渲染
test测试相关test(auth): 补充 token 过期的边界用例
chore构建/工具链chore(deps): 升级 Next.js 到 16.1

这套约定的好处不只是「看起来整齐」。它让 git log 变成了可查询的数据库——你可以用 git log --oneline | grep "^feat" 快速找出所有功能变更,用 git log --grep="auth" 定位所有鉴权相关的提交。更重要的是,它能驱动自动化工具:semantic-release 可以根据 type 自动决定版本号,standard-version 可以自动生成 changelog。

但 Conventional Commits 只是格式层面的约定。格式正确不等于信息有效fix: 修改 merge.ts 完全符合 Conventional Commits 规范,但它依然是一条无用的提交信息。格式是骨架,意图是灵魂。

正文:标题装不下的上下文放这里

简单的提交可以只有标题。但涉及复杂逻辑、业务决策、风险操作的提交,正文是必要的。

正文不是作文,不需要起承转合。它应该用最短的篇幅回答这几个问题:

流程图画布 · 115%
Mermaid 流程图加载中...

一个完整的正文示例:

fix(classify): 修复分类页本地文章被远端覆盖的问题

背景:
用户反馈在弱网环境下编辑的草稿文章,切换到其他页面后丢失。
排查发现是页面 focus 时触发了全量同步,本地未发布的草稿被
远端已发布版本覆盖。

方案:
调整 ArticleSyncService 的合并策略:
- 本地有 draft 状态的文章:保留本地版本,标记为待同步
- 本地无草稿的文章:正常用远端数据更新
- 冲突时生成合并提示,让用户手动选择

影响范围:
仅影响分类页的文章同步逻辑,不影响编辑器内的实时保存。
关联 issue: #1234

验证:
- 新增单元测试覆盖 3 种合并场景
- 本地模拟弱网环境验证草稿不丢失

风险:
合并策略变更可能影响离线编辑场景,建议上线后关注
offline-sync 相关的错误日志。

这段正文大概 200 字,但它让任何一个同事——包括三个月后的你自己——都能在不看代码的情况下理解:出了什么问题、怎么解决的、有没有副作用。这是最高性价比的文档。

一个提交,一个主题

这条原则说起来简单,做起来很难。

赶 deadline 的时候,我们经常会把一个功能开发、两个 bug 修复、一段代码格式化和一个 README 更新塞进同一个提交。提交信息写「完成分类页开发」,然后万事大吉。

但这种提交在历史追踪时是灾难。当分类页的某个 bug 需要回滚时,你发现无法只回滚那一个 fix——它和其他变化混在同一个 commit 里。你要么全部回滚,要么手动 cherry-pick 代码片段。

能拆就拆。 一个提交只做一件事,提交信息只描述那一件事。

提交粒度提交信息示例可追踪性可回滚性
一个大提交feat: 完成分类页开发差——无法定位具体变更差——回滚会误伤
按职责拆分feat(classify): 新增分类页列表渲染
按职责拆分fix(classify): 修复分类页空状态显示
按职责拆分refactor(classify): 提取文章卡片为独立组件
按职责拆分style(classify): 统一分类页间距和字体

拆分的成本看起来高,但它省下的时间会在第一次排查线上问题时加倍回来。

好坏对比:一张表看完

把前面的案例汇总成一张对照表,方便快速参考:

场景❌ 坏的写法✅ 好的写法差距在哪
修 bugfix: 修复 bugfix(auth): 修复会话过期后的无限重定向具体行为 + 定位
加功能feat: 新增搜索功能feat(search): 支持按标签和日期范围过滤功能边界清晰
重构refactor: 优化代码结构refactor(order): switch-case 改为状态模式,降低分支复杂度具体手法 + 动机
改文件fix: 修改 merge.tsfix(classify): 本地草稿优先于远端数据行为描述而非文件名
改样式style: 调整样式style(header): 导航栏在移动端增加底部安全区具体组件 + 具体变化
改配置chore: 更新配置chore(ci): Node 22 升级到 22.16 修复 TLS 兼容性版本 + 原因
文档docs: 更新文档docs(api): 补充 token 刷新接口的错误码说明具体接口 + 具体内容
大杂烩feat: 完成首页开发拆分为多个单一主题提交一个提交一件事

团队协作中的约定

一个人写代码时,commit message 的质量影响的是你自己。但在团队里,它是一个协作契约。

我的经验是,与其指望每个人自发写好提交信息,不如把规范写进工具链。具体做法:

  1. 项目根目录放 .commitlintrc:配置 Conventional Commits 的校验规则。
  2. 用 husky 或 lefthook 挂 pre-commit hook:提交前自动检查格式。
  3. CI 里加 commitlint 步骤:格式不对的提交直接拒绝合并。
  4. 在 CONTRIBUTING.md 里写清楚规范:不是写「请好好写提交信息」,而是给出具体示例和反面教材。

工具只能保证格式正确,不能保证信息有效。但工具能建立习惯——当团队每个人都按照 Conventional Commits 的格式写提交信息时,至少不会出现 fix bug 这种完全无效的信息。

还有一个容易被忽略的点:rebase 的时候整理提交信息。开发过程中有 WIP 提交很正常,但在合并到主分支之前,用 git rebase -i 把零碎的提交整理成清晰的、单一主题的提交,并重新写好提交信息。这不是洁癖,这是对团队的尊重。你的同事在半年后 git blame 到你的提交时,会感谢你的。

提交前自检清单

每次写 commit message 时,对照这个清单过一遍。刚开始可能觉得繁琐,但形成习惯后只需要几秒钟:

  • 标题描述了行为变化,而不是文件名或函数名
  • 标题使用祈使语气(加上「如果应用这个提交,将会……」能读通)
  • 标题不超过 72 个字符
  • 标题末尾没有句号
  • 如果项目使用 Conventional Commits,type 和 scope 正确
  • 正文说明了「为什么改」,不只是「改了什么」
  • 正文包含了足够的上下文,让不了解这个模块的人也能理解
  • 涉及 bug 修复时,正文描述了问题的复现条件
  • 涉及风险操作时,正文包含了回滚方案或注意事项
  • 这个提交只做了一件事——如果不是,考虑拆分
  • 没有包含 WIPtmp临时 等调试痕迹
  • 拼写检查通过,中英文之间有空格

总结

commit message 不是形式主义,它是团队协作里成本最低、收益最高的文档形式。写好一条提交信息不需要文学功底,只需要你停下来想一想:三个月后的人看到这条信息,能不能在不看代码的情况下理解我做了什么、为什么做。

diff 告诉你是什么变了。commit message 应该告诉人为什么变。

当你养成这个习惯之后,你会发现 git log 不再是天书,而是一本有头有尾的项目演进史。


参考资料

Footnotes

  1. The art of writing meaningful Git commit messages - Thoughtbot

  2. Conventional Commits Specification v1.0.0

评论 0

0 / 1000