两年前我第一次意识到提交信息需要规范化,是因为一次版本回溯:某位同事把一段时间内的所有改动全部提交成“update”“fix”这样的消息,三个月后我为了定位一个线上问题,在git log --oneline里翻了十几页,硬是没看出来哪条提交跟业务变更有关,只能挨个 diff。从那天起我就开始研究 Commitlint,也就是 Git 提交信息规范校验工具,并且把团队里的提交流水线整体改造了一遍。
这篇内容适合正在做前端或后端工程化、希望让 Git 提交记录变得清晰可查的开发者。我会从 Commitlint 实际解决什么问题讲起,覆盖安装接入、规则配置、底层校验链路、Husky 和 lint-staged 联动、老仓库渐进式接入,还有我在真实项目里踩过的坑。你可以把它当成一份可以直接照做的实操笔记,也可以把它当成理解 Git 提交信息规范背后机制的原理文章。
1. 一次“提交信息胡写”引发的血案:Commitlint 到底在拦什么
1.1 提交信息混乱的真实成本
很多团队在最开始没有规范 Git 提交信息,原因很朴素:觉得提交信息只是写给自己看的,随便写写无所谓。但等仓库维护到一定规模,这个不起眼的习惯会变成巨大的成本。
举几个真实场景,你应该多少遇到过:
- 版本回退时,想找到“某个功能最后一次稳定版本”的提交,结果
git log --oneline全是update、fix bug、aaa,根本无法快速定位边界。 - 生成 changelog 时,想按功能、修复、破坏性变更分类统计,但提交信息没有任何结构化标识,只能人工一条条猜。
- 代码评审时,PR 里的提交信息无法传达改动意图,评审人必须完整看一遍 diff 才知道在改什么,沟通成本翻倍。
这些问题的共同根源不是“开发者不会写”,而是“没有任何机制兜底”。Commitlint 做的事情很简单:在提交发生时,用规则检查提交信息字符串,不符合规范就直接挡住,不让这次提交“污染”历史记录。它不检查代码质量,也不检查代码风格,只管提交信息长什么样。
1.2 Commitlint 在哪一层工作
要理解 Commitlint 的职责边界,得先看一次提交发生时 Git 的整个流程。当你在终端敲下git commit -m "xxx",Git 本身不会关心消息内容是否合理,它只负责把这次改动记录到版本历史里。
Git 提供了一种称之为 Hooks 的机制,可以在提交生命周期里挂脚本。Commitlint 通常是挂在commit-msg这个 Hook 上:Git 已经拿到了你的提交信息,把它传给commit-msg脚本,然后 Commitlint 对这段字符串做校验,校验通过才允许提交继续,校验失败就返回非零退出码,Git 会中止这次提交。
这个设计和代码检查工具完全不同。代码检查工具你可以在编辑器里实时看到提示,Commitlint 更像一道拦截闸门,在“提交这一瞬间”集中工作。这也决定了它的配置思路:规则要清晰、执行要严格、反馈要友好,否则团队成员的第一反应会是“这破工具又拦我”。
1.3 两个常见的误解
我在给团队介绍 Commitlint 时,经常被问到两个问题,这里先说清楚。
第一,Commitlint 不替代 code review。代码评审要求的是“看懂代码是否合理”,Commitlint 只保证“提交信息表达了改动意图”。它们一个看内容,一个看门面,互补但不重叠。
第二,git commit --no-verify可以绕过 Commitlint。这件事很重要,后面我还会专门讲。Commitlint 属于“君子协定”方案,它防得住大多数场景,但管不住故意跳过 Hook 的行为。真正落地时既要靠工具兜底,也要靠团队形成约定。
2. 跑通最小闭环:安装、接钩子、亲手触发一次校验失败
2.1 环境准备与版本选择
Commitlint 官方维护了两个核心包:@commitlint/cli是命令行工具,负责解析参数、执行校验;@commitlint/config-conventional是一套现成的默认规则,按 Conventional Commits 规范配置好了常用规则。
安装时我会建议用 pnpm 或 npm 装为 devDependencies,命令类似下面这样:
npm install --save-dev @commitlint/cli @commitlint/config-conventional如果你用 pnpm
pnpm add -D @commitlint/cli @commitlint/config-conventional
注意 Node 版本。Commitlint 最近的版本要求 Node 18 以上,如果你的开发环境还停留在 Node 16,安装新版本会直接报错。这时要么升级 Node,要么安装旧的兼容版本,比如固定@commitlint/cli@17。我的建议是尽早升级 Node,旧版本虽然能用,但后续 Hooks 工具链的兼容性会越来越难办。
2.2 创建一个最小配置文件
在项目根目录新增commitlint.config.js,内容如下:
export default { extends: ['@commitlint/config-conventional'], };之所以用这种写法,是因为 Commitlint 支持extends继承一组预设规则,你不用自己把几十条规则逐个写一遍。@commitlint/config-conventional预设做的第一件事是定义了type的可选值范围:feat、fix、docs、style、refactor、perf、test、build、ci、chore、revert等。
如果项目用的是 CommonJS,也可以写成:
module.exports = { extends: ['@commitlint/config-conventional'], };两种写法二选一,看你项目的模块体系。我用export default是因为现在大多数新项目都切到 ESM 了。
2.3 接入 Husky:让 Git 在提交时自动调用 Commitlint
配置文件准备好了,但 Commitlint 不会自己跑,它需要被 Git Hook 触发。目前最常用的方式是配合 Husky,它负责管理 Git Hooks,让配置可以在仓库里“提交即生效”。
Husky 的版本差异比较大,我在实际接项目时吃过亏,这里把两个常见思路都讲清楚。
如果你用的是 Husky 4 及以前的版本,Husky 会读取.huskyrc或 package.json 里的husky.hooks配置,Commitlint 的钩子写起来很直接:
{ "husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } } }Husky 5 之后整个设计变了,不再依赖.huskyrc,而是要求在仓库里初始化.husky/目录,并手动生成 Hook 脚本文件。以 Husky 8 或 9 为例:
npx husky init然后编辑生成的.husky/commit-msg文件:
#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx --no -- commitlint --edit "$1"$1是 Git 传递给 commit-msg Hook 的文件路径,里面存着这次提交的完整信息。npx --no -- commitlint --edit "$1"的意思是通过 npx 临时运行commitlint命令行工具,读取 Git 传过来的那个临时文件并校验。
很多新手在这里容易踩坑:以为装完 Husky 就万事大吉,结果发现.husky目录没有提交到仓库,或者npx husky init执行时没有把 hook 脚本生成到正确路径。我现在的习惯是:装完 Husky 后第一件事检查.husky/commit-msg文件是否存在,然后看一眼git config core.hooksPath,Husky 会把 hooks 路径指向.husky目录。
2.4 亲手触发一次校验失败
接入完成后,别急着写规范代码,先故意写一条不规范的提交信息试试。比如:
git add . git commit -m "update index.js"Commitlint 会报错,大致意思是:
⧗ input: update index.js ✖ subject may not be empty [subject-empty] ✖ type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum] ✖ found 2 problems, 0 warnings这里的type就是提交信息的第一个词,比如feat或fix。因为写的是update,没有匹配到预设里的 type 枚举,所以校验直接失败。改成fix: update index.js就能通过,前提是你确实是在修 bug,而不是加功能。
亲手触发一次失败很重要。工具报错时,错误信息里会精确告诉你是哪条规则、期望值是什么、当前值是什么。团队成员第一次遇到报错时,如果错误信息够清晰,就不会产生“莫名其妙被拦”的抵触情绪。这也是我一直强调“规则不要贪多”的原因:报错信息太晦涩,再好的规范也推不动。
2.5 校验历史提交的备用命令
如果只想手动验证某段提交消息是否满足规则,不用走 Hook,可以用commitlint的 from/to 参数:
npx commitlint --from HEAD~1 --to HEAD这样会读取当前最近一次提交的完整消息,把它解析后按规则校验一遍。我经常在 CI 流水线里用这种形式做历史提交的合规检查。
3. 别急着改规则,先把校验链路看清:parse、match、ignored
3.1 parse:提交信息是如何被拆解的
Commitlint 对提交规则的检查并不是简单地对整个字符串做正则匹配,它先要把提交信息解析成标准格式,再逐段校验。这个解析过程主要通过@commitlint/parse完成。
一条符合 Conventional Commits 规范的提交信息长这样:
feat(authentication): add phone login support - implement sms verification - add rate limiting Closes #1234Commitlint 会把它拆成 header、body、footer 三个部分。header 是第一行,body 是空行之后的所有内容直到下一个空行,footer 是最后几行。header 本身再拆成 type、scope、subject。
比如对于feat(authentication): add phone login support,解析结果是:
- type:
feat - scope:
authentication - subject:
add phone login support
可能有人想问:如果提交信息里没有括号,比如fix: update button style,scope 就是空;如果 subject 根本没有填,比如feat:,Commitlint 就会报subject-empty。理解了 parse,你再去看那些看起来像魔法一样的报错信息,就会明白它们其实是对解析结果做规则校验得来的结论。
3.2 match:rules 是如何一条条套用的
解析输出结果是第一层,第二层是规则匹配。Commitlint 的rules配置是个对象,每个 key 对应一条规则名,value 是一个数组,通常包含三个元素:level和applicable和config。
举个例子:
rules: { 'header-max-length': [2, 'always', 100], }意思是:这条规则名叫header-max-length,规则等级是 2(代表 error,不合法就报错;等级 1 代表 warning,不合法只提醒;等级 0 代表关闭),规则适用条件是always(代表“总是校验”),参数是 100(header 最长 100 个字符)。
如果一条规则是[2, 'never', 'foo'],说明“永远不能匹配到 foo”。这类规则在检查某些不该出现的字符时很有用。
多个规则之间是“与”的关系,一条提交必须同时满足所有 enabled 规则才通过。所以当你看到 Commitlint 一条报错后面跟着三四条问题,别慌,它只是在把所有规则跑完之后汇总输出,不是只报第一条就停了。
3.3 ignored:哪些提交可以不走校验流程
Commitlint 默认不会校验所有消息,它会先判断这条提交是否属于“被忽略”的类别。默认的ignored列表覆盖了 merge 提交、revert提交、fixup!、squash!这类 Git 自动生成的提交。
我在实际项目里遇到过一种情况:团队使用自动化的依赖更新机器人在仓库里提交更新,机器人每次提交的信息都是固定的chore(deps): update dependency xxx这类格式,本来就能通过校验。但如果哪天机器人工具版本变了,提交格式变了,可能突然挡住整个自动流程。解决方案是在 commitlint 配置里用ignored字段补上自定义忽略匹配:
export default { extends: ['@commitlint/config-conventional'], ignored: [ /^chore\(deps\):/, /^\[bot\]/, ], };ignored数组里的正则只要匹配上消息,Commitlint 就直接放行,不再走规则校验。另外还有一个defaultIgnores配置,如果你连默认的 merge、revert 都希望校验,可以把它设为false,但我通常不建议这么做,因为 Git 自动生成的消息有它自己的格式,强行校验很容易误伤。
3.4 规则块与 condition 的组合逻辑
再往深一层,规则里还有一个很容易被忽略的prompt配置和plugins机制。plugins允许你引入扩展的自定义规则集合,比如检查 footer 是否包含BREAKING CHANGE、检查 body 是否包含工单号等。这些扩展规则本身不复杂,但能极大提升团队特殊需求的适配能力。
我看到很多团队最终的 commitlint 配置其实只用了官方预设,没自定义任何规则,原因不是“不需要”,而是不知道还能自定义。到第四章我详细讲规则怎么改,这里先明确一个核心逻辑:Commitlint 的三层链路是 parse 拆解、rules 匹配、ignored 豁免,理解了这三层,你才能根据团队需求写出有针对性的配置。
4. 规则体系拆解:官方预设够用,但团队规范要自己动手改
4.1@commitlint/config-conventional默认给了什么
官方预设基于 Conventional Commits 规范,我整理了一张常用规则表,方便对照:
| 规则名 | 等级与参数 | 含义 |
|---|---|---|
type-enum | [2, 'always', [...]] | 第一个词必须是枚举里的 type |
type-case | [2, 'always', 'lower-case'] | type 必须小写 |
subject-empty | [2, 'never'] | subject 不能为空 |
subject-full-stop | [2, 'never', '.'] | subject 结尾不能有句点 |
header-max-length | [2, 'always', 100] | header 总长不超过 100 字符 |
body-leading-blank | [2, 'always'] | body 前必须有空行 |
footer-leading-blank | [2, 'always'] | footer 前必须有空行 |
预设的好处是开箱即用,但它也假设所有项目都遵循同一套规范。我见过不少团队直接 extends 预设后就不动了,结果subject-case的默认规则要求在 subject 里不能首字母大写、不能无大小写,导致写feat: Add login button这种英文语句被拦,团队成员误以为“系统要求消息必须全小写”,其实是没懂预设规则的含义。
4.2 常见规则自定义写法
以“让提交信息更贴近我们团队习惯”为目标,我给出几个高频率修改。
比如,预设的 type 列表里没有init和wip,如果你的项目希望有这两类,在配置里覆盖type-enum即可:
export default { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert', 'init', 'wip']], 'header-max-length': [2, 'always', 120], 'subject-full-stop': [0, 'never'], }, };这里[0, 'never']意味着把这条规则直接关闭。我见过有团队把subject-case关闭,只保留 type 和 subject 非空,原因是团队成员写中英文混合 subject 时,大小写判断过于敏感,经常误报。
规则改动有一个原则:少改就是多改。每条规则每次改动都要考虑“团队成员会不会因为这个报错而困惑”,这也是为什么我倾向先跑通预设,观察两周,再根据真实报错收敛规则。
4.3 除了 rules,还可以配置什么
commitlint.config.js除了extends和rules,常见的还有:
parserPreset:替换默认解析器。默认解析器够用,但如果你自定义了提交格式,比如有!表示 breaking change,就要调整 parser preset。helpUrl:报错时展示的“查看文档”链接,可以指到团队内部的规范文档。plugins:引入本地自定义规则插件。prompt:如果你同时使用@commitlint/cz-commitlint做交互式提交,这里可以控制交互表单的字段配置。
我自己维护多个项目时,最常用的是把helpUrl改成团队规范速查页面。这样报错时直接给出一条链接,团队成员的自主学习成本会低很多。
4.4 多仓库共享配置:一个配置包的经验
当项目数量上来之后,在每一个仓库里复制粘贴同样的 commitlint 配置很痛苦。我前后用过两种方案:
第一种是在 monorepo 根目录放一份配置,子包通过 extends 引用相对路径,比如:
export default { extends: ['../../commitlint.base.js'], };第二种更干净,是发布一个内部 npm 包,例如@your-team/commitlint-config,然后每个仓库统一extends: ['@your-team/commitlint-config']。这样所有仓库的提交规范只受这一个包控制,改一个版本就能全局同步。
第二种方案之所以更靠谱,是因为 monorepo 的相对路径一旦目录层级变化就容易断,而且不同仓库很难统一版本。我强烈建议有多个项目的团队走配置包方案,哪怕包一开始只封装了 rules 和 helpUrl 两件事,日后的收益也远超那点初始化成本。
5. 与 Husky、lint-staged、Commitizen 组成的提交流水线
5.1 提交信息生成:与其让人类打字,不如用工具引导
Commitlint 把提交信息拦住了,但团队成员被拦住了就得重新敲一遍。如果团队里有人对规范不熟,反复被拦会非常烦。一个很自然的补充方案是在提交阶段提供一个交互式向导,让开发者“选”而不是“敲”。
这个路子目前最成熟的是 Commitizen 生态。你安装commitizen和对应的适配器,然后在 package.json 里配置:
{ "scripts": { "commit": "cz" }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } } }之后团队成员不走裸的git commit,而是运行npm run commit,会看到一个交互式表单:让它选择 type,输入 scope、subject、body、footer。最终生成的提交信息天然符合 Conventional Commits 规范。
这里我不展开适配器内部实现,只说结论:Commitizen 负责“生成符合规范的消息”,Commitlint 负责“兜底校验最终消息”。两者一前一后,比单独上 Commitlint 的落地体验好很多。我在推给非资深前端同事时,这套组合几乎是唯一没引发大规模抱怨的方案。
5.2 pre-commit 和 commit-msg 的分工
接完 Commitizen 之后,Husky 通常开始接管不止一个 Hook。我把一条很典型的提交流水线展示一下:
pre-commit:跑 lint-staged,对暂存区代码做代码风格检查和单元测试过滤;不过这一阶段和 Commitlint 无关。commit-msg:跑 Commitlint,校验提交信息格式。pre-push:可选,跑全量测试或类型检查,防止有明显问题的代码被推到远端。
这条流水线的逻辑是一件事一个 Hook 管,互不耦合。很多新手会误以为pre-commit也要跑 Commitlint,其实没必要,因为没有提交信息可校验。把责任切清楚后,报错定位也会容易很多:某个环节挂了,直接看对应 Hook 的脚本,不会互相干扰。
5.3 lint-staged 与 Commitlint 的真实关系
lint-staged 和 Commitlint 经常出现在同一篇工程化文章里,但它们解决的是完全不同的问题。lint-staged 只管暂存区文件,在 pre-commit 阶段把改动文件拿去跑 ESLint 或 Prettier,它不关心提交信息。Commitlint 只看消息字符串,不碰任何代码文件。
实际项目中两者经常一起配置,因为它们都依赖 Husky 这个宿主:
npx lint-staged这段要放在.husky/pre-commit,Commitlint 命令则在.husky/commit-msg。如果你在接入时搞混了,把 commitlint 写在 pre-commit 里,也能跑,但拿不到 Git 正在生成的临时提交信息文件,只能退化成校验--from HEAD~1 --to HEAD,可读性很差,所以我建议还是各司其职。
5.4 提交规范与 changelog 自动化的连续性
Commitlint 校验通过的信息,其实还蕴含另一重价值:它们可以被自动化工具解析,生成可读的更新日志。比如安装了 conventional commit 生态中的 changelog 工具后,可以根据提交里的feat、fix、BREAKING CHANGE自动整理 release note,省去手动整理日志的时间。
这就是为什么我总强调“提交信息规范值得下功夫做”。Commitlint 看似只是拦住了几条不规范消息,实际上是整个提交数据标准化的开端。一旦历史提交都符合规范,后续的自动化工具链就有了一致的数据源,这在项目规模变大后尤其有价值。
6. 老仓库渐进式接入,以及日常使用中的常见坑
6.1 存量仓库怎么办:先管新提交,不追溯历史
很多团队担心“老项目一接 Commitlint,历史提交全是非法状态,CI 会不会炸?”答案是:只要你只在提交时校验,历史提交不会被自动重审。Commitlint 是在新提交发生时读取“当前要提交的消息”,不会主动扫描整个 Git 历史。
如果你确实想在 CI 里做全量历史审计,可以按仓库实际情况分阶段放开。我的做法是先在本地接入 Hook,观察一两周,收集真实报错,避免 CI 直接全量卡死。
如果要在 CI 里做渐进式合规检查,可以用 from/to 限定范围,比如:
npx commitlint --from $(git rev-parse origin/master) --to HEAD这样只检查从那之后的新提交,不影响历史。等团队规范稳定了,再慢慢扩大到整条分支对比区间。
6.2 被绕过的现实:--no-verify与 amend 提交
Commitlint 能管住“随手提交”,但管不住“故意绕过”。Git 提供了--no-verify参数,可以跳过所有 Hook。这在紧急修复时会救命,但也可能成为破坏规范的借口。
我的处理方式是:不在工具层面完全堵死绕过,过度设防只会增加使用成本。真正有效的是两条配合策略:第一,CI 流水线里也加一轮提交信息检查,本地能绕,远端绕不了;第二,团队里明确约定--no-verify只允许用于紧急热修,并且事后必须补上规范提交。
还有一类隐蔽问题是git commit --amend。当开发者用 amend 修改上一次提交时,如果消息被改坏了,commit-msgHook 会基于新消息重新校验,所以通常能拦住。但在rebase -i的 squash 场景里,多个提交被合并成一个后,新生成的消息可能会绕过某些 hook 的完整检查,这时候就需要靠 CI 的覆盖检查兜底。
6.3 Windows 环境与命令转义:少量却高频的坑
我在 Windows 环境下帮人排查 Commitlint 时,最容易出的问题是命令行传参。比如想临时测试一条消息:
npx commitlint -m "feat: add new page"在 cmd 里没问题,但到了 PowerShell 下,某些特殊字符(如括号、感叹号、;)会被 shell 解释,导致消息内容被截断或转义,Commitlint 收到的消息和你看到的不一致。遇到这种情况,不要花时间跟 shell 争论,直接把消息写入文件,用--edit或-e指定文件路径来校验,绕开转义问题。
还有一个高频问题:Husky 脚本的 shebang 行。在 Windows 上如果.husky/commit-msg开头不是#!/usr/bin/env sh,hook 可能直接用错误解释器执行。我建议在仓库初始化时就用官方生成的模板,不要手搓 hook 文件。
6.4 我的一些配置建议和团队推行体会
最后分享一个我在多个团队里验证过的实操结论:Commitlint 配置别贪多,规则数量控制在个位数以内,重点是 type-enum、subject-empty、header-max-length 这类核心检查。规则越多,误报越多,最终团队成员不是感谢规范,而是恨规范。
推行时也别一步到位。我通常先给团队两周“只提示不拦死”的阶段,把规则的 level 设为 1,也就是 warning,让大家在 IDE 里看到提示,提交时不会被打断。等大家习惯了,再改成 level 2,正式拦截。这个过程看起来慢,但比周五突然强制上线、周一团队集体炸锅要高效得多。
另一个细节是消息模板。即便有了 Commitizen,我也建议把示例直接写在团队规范文档里,比如:
feat(模块名): 简短描述 详细说明,可包含改动原因、影响范围、注意事项 Closes #工单号这套模板其实和 Commitlint 无关,但它决定了团队提交信息的“下限”。Commitlint 只是保证格式合法,模板才是让提交信息真正可读的关键。
回头看我这几年的实践,Commitlint 从来不是一个“装了就完事”的工具。它是一套提交数据标准化流程的入口,需要配合 Husky、Commitizen、团队规范和 CI 才能发挥完整价值。如果你刚开始接触,我建议先跑通第五、六章提到的最小闭环:装好两个官方包、挂上 commit-msg Hook、跑一次失败的校验,然后观察团队真实报错,再逐步调整规则。等你发现某一天git log --oneline能快速读懂半年前的提交意图,就会明白当初这套折腾是值得的。