前两天帮一家创业公司做代码规范落地,他们的仓库提交信息清一色是fix、update、aaa,偶尔还夹杂着:wq这种从 vim 里带出来的“残留物”。release 的时候想自动生成 changelog,结果全得手搓;线上出了 bug 想用 git log 定位回归版本,翻半天也看不出哪条提交干了什么。这种场景你应该不陌生——Commitlint 就是专门解决这个问题的:它是一把守在 git 提交门口的“检查尺”,通过强制校验git commit信息的格式,让每次提交的 message 都遵循同一个规范。
这套东西非常适合三类人来用:一是想给个人开源项目建立规范习惯的开发者,二是团队里被混乱提交历史折磨的维护者,三是准备接入自动化发布、想靠 commit message 生成 changelog 的工程化发烧友。全文会从“为什么要立规矩”讲起,接着聊方案选型、配置规则拆解、完整落地链路和常见问题排查,尽量把每个“为什么这么做”都讲透。
1. 为什么要给提交信息立规矩
先讲个我自己的经历。有次排查线上一个诡异的渲染问题,需要定位到具体是哪次提交引入的。git log 拉出来一长串,格式大概是这样的:update、fix bug、change、modify、xxx。你想在这种历史里找线索,基本等于大海捞针。当时我就在想,如果每一条提交都老老实实写着fix(modal): 修复关闭后滚动条不还原,这个问题五分钟就能定位完。
提交信息看着是小事,但它是整个工程协作里被复用次数最多的“元数据”。每一次回滚要找它,写 changelog 要读它,code review 要看它,甚至 CI 的版本号计算也要依赖它。用生活的例子类比:快递面单上如果永远只写“东西”两个字,快递员根本没法分拣;提交信息就是软件仓库的面单,写得越清楚,后续的流转就越高效。
那“规范”具体长什么样?目前社区最流行、也最值得推荐的,是 Conventional Commits 约定,也就是我们常说的“约定式提交”。核心格式就一句话:
type(scope): subjecttype表示提交类型,比如feat是新功能、fix是修 bug、docs是文档改动;scope是影响范围,比如modal、api、cli;subject是简短描述。看起来很简单对吧?但问题在于,这个规范如果不靠工具强制,光靠自觉是推不动的。团队里总有那么几个人习惯性写update,你提醒一次他改一次,下次照样犯。
Commitlint 干的事情,就是在 commit message 进入仓库历史之前,用配置文件里的规则对它做一次完整的静态检查。格式不对就拒绝提交,并且告诉你哪里不对、该怎么改。它的角色更像“门卫”而非“老师”——不负责教你写,只负责拦住不合规的东西。这带来的直接好处有三个:一是 git log 立刻变得可读,人眼扫一遍就能理解每个提交的目的;二是后续可以靠工具自动分析提交历史,生成 changelog、计算版本号;三是 code review 和 bug 定位有了清晰的搜索索引,git log --grep随便一查都是准确的。
2. Commitlint 方案选型:为什么是它
我接触过不少团队在处理“提交信息规范”这件事时,第一反应都是自己写一段正则或者搞个 VSCode 插件人工提醒。我得说,这属于典型的重复造轮子,而且造出来的轮子多半不圆。
自己写校验脚本的问题在哪?首先是规则覆盖不全。你考虑到了 type 必须是有意义的英文单词,但未必能想到 header 行长度限制、正文前空行要求、footer 里 breaking change 的声明格式。等你想全了,脚本已经写了一百多行,还要维护。其次是没有扩展性。今天要求 type 小写,明天要求 scope 必须从列表里选,后天要求 description 不允许句号结尾,每加一条规则就得改代码。这种“硬编码校验”跟广告牌上的临时通知没什么区别,改起来累,执行起来也容易出纰漏。
Commitlint 解决这些问题的方式,是把校验逻辑拆成了“规则引擎”和“规则集”两层。规则引擎只负责框架:怎么读信息、怎么跑规则、怎么报告错误;规则集是具体的规则定义,比如type-enum要求类型必须属于某个枚举、header-max-length限制首行长度。两者分离的好处是,你可以直接继承社区维护的现成规则集,也可以像搭积木一样按团队需求启停、覆盖单条规则。这个设计类似后端的中间件机制——主体逻辑固定,插拔式的扩展点足够干净。
另外一个关键对比是生态。Commitlint 背后有活跃的社区,官方维护了@commitlint/config-conventional这套推荐配置,很多知名开源项目都在用。这意味着你踩过的坑,大概率别人也踩过,搜索引擎一抓就有解决方案。而自研方案一旦出问题,调试的成本完全自己扛。做一个简单的对比:
| 对比维度 | Commitlint | 自研校验脚本 |
|---|---|---|
| 规则丰富度 | 内置 20+ 条规则,可继承社区配置 | 需求靠一点点写,边界多易遗漏 |
| 扩展性 | 配置文件启停规则,不用改代码 | 加规则基本等于改代码重发 |
| 社区支持 | 覆盖绝大多数已知问题 | 出问题只能自己排查 |
| 与 hooks/CI 集成 | 官方文档提供了标准做法 | 需要自己搭链路 |
| 团队上手成本 | 配置熟悉之后很轻松 | 需要维护“私有规范” |
还有一个常见疑问:Commitlint 和 Commitizen、husky、lint-staged 这些工具到底是什么关系,有人说装了全家桶,有人只用其中一个。我用个比喻来解释:Commitlint 是“检查官”,专门负责验收提交信息合不合规;Commitizen 是“填写向导”,启动交互式终端引导你填写提交信息,保证你交上来的东西大概率合规;husky 是“门卫”,负责在 git 钩子上拦人,让“检查官”有活干;lint-staged 是“分拣员”,专门处理暂存区文件的代码检查,跟提交信息不是一个维度。实操里最常见的搭配是 husky 加 Commitlint,Commitizen 属于“可选优化项”,让团队成员更省力。
3. 核心配置逐项拆解
Commitlint 的配置入口很灵活,可以写在独立配置文件里,也可以塞进package.json。常见的几种形式:
commitlint.config.js或commitlint.config.cjs—— 导出模块对象;.commitlintrc.json/.commitlintrc.yaml/.commitlintrc.yml/.commitlintrc.js—— 以文件形式存放;package.json里的commitlint字段。
我结合实际经验建议:新项目直接用commitlint.config.js,因为配置里难免要加注释、写函数做动态判断,JS 文件能同时支持这两种需求。尤其当你用@commitlint/config-conventional做基础,再覆盖个别规则时,JS 对象的表达优势非常明显。纯 JSON 也可以,但一旦遇到“根据环境变量切换不同类型”这种动态需求,就要替换配置来源,麻烦不少。
理解 Commitlint 的配置,核心是理解规则(rules)的写法。每条规则都是一个三元组:
'rule-name': [severity, condition, value]severity表示严重级别:0是关闭规则,1是警告(提交不拦截但会提示),2是错误(直接拒绝提交)。condition表示规则条件:always表示“只要提交信息满足 value 就行”,never表示“只要提交信息不满足 value 就行”。value是具体的参数值,不同规则含义不同。
拿最常用的一条规则举例:
'type-enum': [2, 'always', ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'revert']]这条规则的意思是:提交信息的 type 部分,必须属于后面这个枚举列表,否则报错。2代表报错并且阻断提交,always代表“永远遵守枚举限制”。如果把条件改成'never',意思就变成“type 绝不能在这份列表里”,语义完全反转,所以写的时候要格外小心条件这个位置。
再比如首行长度限制:
'header-max-length': [2, 'always', 100]为什么要限制成 100?因为 Git 本身的输出、终端的默认宽度、还有常见 Git 托管平台在展示 commit message 的时候,太长的首行会被截断。100 个半角字符是社区多年实践下来的一个比较稳妥的经验值,既能容纳较完整的描述,又不会在多数界面里显示不全。
用了@commitlint/config-conventional的话,你等于自动获得了一整套经过社区验证的规则,其中最常用的包括:
| 规则 | 默认值 | 说明 |
|---|---|---|
type-enum | feat/fix/docs/style/refactor/perf/test/chore/revert 等 | 类型必须属于枚举 |
type-case | lowercase | type 必须小写 |
type-empty | never | type 不能为空 |
scope-case | lowercase | scope 必须小写 |
subject-case | 一系列空值限制 | description 有多种写法限制 |
subject-empty | never | description 不能为空 |
header-max-length | 100 | 首行总长上限 |
body-leading-blank | always | 正文前必须空一行 |
footer-leading-blank | always | footer 前必须空一行 |
body-leading-blank和footer-leading-blank这两条很多人不理解,觉得有没有空行无所谓。其实空行是给 Git 解析器看的边界标记,有了空行,Git 才分得清哪部分是首行、哪部分是正文、哪部分是 footer。没有空行,后面就算写了 body 也可能被并进 header 里,工具链解析就直接乱套。
配置示例我一般这样写,既保留了社区推荐的绝大部分校验,又纠正了个人项目上的一些小别扭:
module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [ 2, 'always', ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'] ], 'scope-case': [2, 'always', 'lower-case'], 'subject-case': [0], 'body-max-line-length': [2, 'always', 200], 'footer-leading-blank': [2, 'always'] } };这里把subject-case关掉是因为团队里有人习惯在描述末尾加句号、或者开头用中文大写,这类风格问题其实不影响信息提取,卡得太死只会让人觉得规范“反人性”。规则不是越多越严越好,而是越能长期执行越好。凡是容易引发争议、影响人情绪的规则,宁可先放着,等团队形成了基本共识再加。
4. 实操:从零到一接入完整链路
理论聊完,进入动手环节。整套链路我会拆成三个部分:核心工具安装、husky 钩子配置、可选但建议加的交互式填写工具。环境要求先说一句:Node 版本建议 18 及以上,Commitlint 新版本对旧版 Node 支持很有限,有些新语法在低版本 Node 里跑不起来。如果还在用 Node 14 甚至更老的版本,我建议先把 Node 升上去再谈规范,否则后面处处受限。
4.1 安装 Commitlint 和推荐规则集
新建一个空目录初始化项目,或者直接在现有项目里执行:
npm init -y npm install --save-dev @commitlint/cli @commitlint/config-conventional@commitlint/cli是校验主程序,@commitlint/config-conventional是上述那套社区配置。两个包是配套关系,cli是发动机,config-conventional是预设路线,缺一不可。安装完检查一下node_modules/.bin/commitlint是否存在,确认命令已经就位。
创建配置文件,放在项目根目录,命名为commitlint.config.js。刚上手的话,最简单的写法就是继承推荐配置,一行都不用改:
module.exports = { extends: ['@commitlint/config-conventional'] };先跑一条校验命令验证一下工具本身是否工作正常。Commitlint 支持通过管道把提交信息喂进去:
echo "hello world" | npx commitlint正常输出会提示input开头的内容不符合格式规范,并列出具体违反的规则。看到类型错误、空值错误之类的报错,说明工具已经跑通了。再试一条规范格式:
echo "chore: init project" | npx commitlint这条没有任何输出、退出码是 0,说明校验通过。这里顺带解释一下:echo "chore: init project"里冒号后面有个空格,这是 Conventional Commits 格式的硬性要求,type(scope): subject结构里冒号和描述之间必须带空格。很多人第一次配置失败,不是什么大问题,就是这个小空格丢了。
4.2 用 husky 把校验接到 Git 钩子上
Commitlint 装好了,只有当它在 commit 这个动作发生自动执行时才有实际价值。最标准的做法是用 husky 注册 Git 的commit-msg钩子。husky 的版本迭代很快,老版本用起来跟新版本有些差异,我以下面这个较新的用法为例。
安装 husky:
npm install --save-dev husky npx husky inithusky init这步会在项目里生成.husky/目录,并在里面放一个pre-commit示例钩子文件。这时需要手动创建一个commit-msg钩子,写入要执行的校验命令。确保commit-msg文件内有执行权限,然后写入:
npx --no -- commitlint --edit $1解释一下这条命令:--no是防止 npx 在缺少本地依赖时自作主张去网络下载,强制使用项目本地安装的 commitlint;--edit $1表示读取 Git 传入的提交信息文件(commit-msg 钩子会收到一个指向临时文件的路径参数),文件里存放的是完整提交信息。
npx --no -- commitlint --edit $1这行命令被很多经验帖直接抄走,但没人解释为什么要这么写。其实核心就一点:--no可以避免“提交人本机没有安装 commitlint 却触发了网络下载”这种不可控情况。团队协作里,不同成员的开发环境差异很大,统一通过npx --no强制走本地依赖,能稳定复现同样结果,避免“我本地跑得好好的,同事那边就报错”的尴尬。
配好之后做个简单的端到端测试:
git add . git commit -m "add new feature"如果配置正确,这次提交会被直接拦截,终端打印出违反的规则列表。改成规范格式再试一次:
git commit -m "feat: add new feature"正常进入提交流程,说明整个链路已经接通。要注意一点:husky init生成的钩子文件,如果你是在 Windows 下用原生 cmd 操作,git commit 的时候可能因为 shell 路径配置出问题,建议改用 Git Bash 环境跑命令,能省掉不少环境方面的坑。
4.3 让待提交信息生成也要“先填表”
钩子强制校验是“事后拦截”,但团队里总有人不胜其烦。为了降低合规门槛,我强烈建议加上交互式提交工具。目前体验最好的是 Commitizen 的cz-git适配器,它能在终端里生成一个带选项的表格,让你选择类型、填影响范围、写描述,生成的一定是规范格式的 message。
安装方式:
npm install --save-dev commitizen cz-git然后在package.json里配置:
{ "scripts": { "commit": "git cz" }, "config": { "commitizen": { "path": "node_modules/cz-git" } } }之后团队成员提交代码不再直接git commit,而是跑npm run commit。终端里会出现类似这样的交互流程:第一步选择提交类型(feat/fix/docs/...),第二步填写影响范围(可跳过),第三步写描述,第四步选择是否有破坏性变更。整个流程走完,工具会自动拼接成一条标准格式的提交信息交到 commit-msg 钩子里,自然就过了 Commitlint 的检查。
用上交互工具后,团队成员上手成本大幅下降,因为工具把“怎么拼格式”这件事接手了,人只需要回答几个傻瓜式问题。把“写格式”的义务从人身上转移给工具,接下来大家才有精力去关心描述写得是否清晰,而不是老在纠结 type 到底是build还是chore。
4.4 别忘了在 CI 里兜底
本地钩子能被--no-verify跳过,这是个公开的“后门”。有些开发者一着急就会用这个参数绕过校验,一次两次还行,次数多了规范就名存实亡了。所以正规的做法是除了本地钩子,还要在 CI 流程里加一道 Commitlint 检查,保证合并到主干分支的代码一定是合规范的。
CI 里跑 Commitlint 跟本地不太一样:本地只需要校验当前这次提交,CI 通常要校验一个分支上所有新增的提交。这时用--from和--to参数指定提交范围:
npx commitlint --from origin/main --to HEAD这条命令会读取从主干分叉到当前 HEAD 之间所有提交信息,逐条校验。无论开发者本地怎么 squash、怎么 reset,只要提交进了这个范围,就会被 CI 卡住。另外提一句,CI 里的校验命令要和本地钩子保持一致,别一个用@commitlint/config-conventional,另一个改了规则集,两边“打架”会让排查变得很费劲。
5. 常见问题与排查实录
接入 Commitlint 过程中总会冒出各种奇奇怪怪的问题。我把这几年积累的典型问题整理成一份问答,按“症状—原因—解法”的顺序写,方便直接对照。
5.1 钩子没生效
最典型的一个表现:配置全部写完,git commit却照样走通,完全没有被拦截。先检查.husky/commit-msg文件是否存在,确认里面的命令是不是npx --no -- commitlint --edit $1。如果文件存在但还是不生效,大概率是 husky 没有正确注册 Git hooks。
新版 husky 通过core.hooksPath指向项目的.husky目录来注册钩子,可以手动检查:
git config --get core.hooksPath如果输出为空,说明没有指向.husky,手动执行一下:
git config core.hooksPath .husky顺便看看.husky/里的各个钩子文件权限是不是可执行,有些项目管理工具会把这些文件当成普通文本存储,导致 Git 无法触发。如果是 Windows + 跨平台协作,这种权限问题尤其常见。
5.2 校验文件内容与改动不匹配
另一个常见问题是:“我明明已经改了文件,commit 还是报错,报的规则跟我的新配置不一样。”这种问题基本都是因为配置文件的加载路径有问题。Commitlint 默认从当前工作目录往上查找配置文件,如果你在子目录里执行 git commit,有时候会找错配置文件,或者根本没找到配置,就用内置默认值执行了。
排查方式很简单:在项目根目录跑一次echo "chore: test" | npx commitlint,如果连这种合法格式都报错,就说明配置没被正确加载。检查文件命名是不是commitlint.config.js,确认文件里用的是 CommonJS 导出(module.exports)还是 ESM 导出,两者写错会导致加载失败。还有一个容易忽略的点:配置文件如果放在子目录里,记得让团队统一把 git 仓库根目录当执行目录。
5.3 type 枚举太严格怎么办
团队项目类型多,某些业务场景需要自定义类型,比如i18n(国际化文案)、ui(UI 调整)、data(数据结构变更)。默认的type-enum列表是不包含这些的,加了就报错,不加又写不出准确语义。解决办法就是在规则里扩展枚举:
module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert', 'i18n', 'ui', 'data' ]] } };注意这里的逻辑:extends负责引入默认规则集,rules里单独写type-enum时会覆盖默认的枚举列表,而不是在默认列表上追加。所以自定义的时候,要把默认的十几个类型跟自定义类一起列全,千万不要只写自己新增的几个。这个坑我见过不止一次——有人只写了['i18n', 'ui'],结果连feat和fix都被拒了,团队直接原地炸锅。
5.4 有人用 --no-verify 绕过提交
本地钩子拦得住普通人,拦不住想“抄近道”的人。git commit --no-verify会跳过所有 Git 钩子,这是 Git 本身就有的功能,既关不掉也不该关,但它确实是个隐患。处理思路不是在本地堵住这个口子,而是让 CI 成为“最后一道防线”。在主干分支保护规则上配置强制状态检查,把 Commitlint 校验作为合并的前置条件,这样本地就算绕过了,合并时依然会被 CI 的 Commitlint 检查拦截。这也再次说明 4.4 里 CI 校验的必要性。
提到绕过没问题,补充个合规细节:--no-verify是 Git 的原有功能,把它描述为“绕过本地钩子的官方开关”比较准确,但我不会在文章里提供任何“如何规避检查”的进一步说明,上面写 CI 兜底就是为了在合规前提下解决这个真实痛点。
5.5 提交信息里带多行正文
有人提交时用了git commit -m "feat: xxx" -m "body line"来写多行 message,结果 Commitlint 报正文或 footer 的空行错误。原因就是前面说过的body-leading-blank/footer-leading-blank规则:正文和 footer 前面必须空一行。如果你拿-m连续传多段内容,Git 默认会自动加空行,一般没问题;但如果你手动拼字符串来指定 message,就容易漏掉空行。诊断时直接输出原始 message 看看:
git log -1 --format=%B如果格式里少了空行分隔,把 message 补上空行即可。还有一种更干净的提交方式:不用-m参数,直接进入编辑器,在编辑器里按“首行 / 空行 / 正文”的结构来写,天然规避这个问题。
6. 团队落地的几点体会
最后聊点跟工具本身无关、却决定这套东西能不能活下去的经验。Commitlint 落地原则上是一项“规则推行”工程,不是“工具安装”工程。我见过不止一个团队,配置倒是很好看,规则写了一大堆,结果推行了两周就形同虚设。原因无外乎几个:一是规则订得太细太严,导致日常提交门槛过高;二是没有配套模板,大家不知道正确写法是什么;三是遇到了不合规的提交就顺手绕过,没有建立必要的审查习惯。
我的建议是,新团队接入的时候,规则先松后紧。第一次只开最核心的几条:type-enum、type-empty、subject-empty、header-max-length。这几条能拦住 90% 的不规范提交,又不会让人烦到想骂人。等大家都形成习惯了,再把scope-case、body-leading-blank之类的细则逐步放开或者加强。这跟健身的时候先养成“每天去健身房”的习惯,再考虑“具体练哪个部位”是一个道理——先确保动作能连续做下去,再追求动作标准。
另外,找一个模板文件挂在仓库里,或者直接接上cz-git交互工具。模板的示例比什么文档都管用:
feat(scope): 添加一个功能 fix(scope): 修复一个问题 docs(scope): 更新一段文档 style(scope): 调整代码格式 refactor(scope): 重构一段逻辑把示例贴在 README 里,新人一眼就知道该怎么下笔。配合npm run commit的交互流程,基本一个星期左右,团队所有人对格式就形成肌肉记忆了。
还有一个细节容易被忽略:提交信息里的subject是给人快速浏览用的,建议尽量写清楚“做了什么”而不是空洞的“修改问题”。Commitlint 只能保证格式正确,保证不了内容质量。我习惯在团队里推荐一个简单的写法模板——动词开头、指向明确、不超过一行能说清。比如fix(order): 修复订单列表金额错位远比fix: fix bug有价值。这不算 Commitlint 的配置能力,但它决定了你的 git log 到底好不好用。
这套东西扩展起来也很有想象空间。Commitlint 的规则校验结果可以被脚本读取,进一步对接自动生成 changelog、计算语义化版本号、驱动发布流程。很多团队在 Commitlint 之上跑一套 release 流水线:开发者按规范提交,CI 自动收集 feats 和 fixes 分类,生成变更日志,再自动 bump 版本号。你往前迈一步就会发现,Commitlint 不是终点,是规范的起点。
我自己现在接手任何项目的第一件事,就是把 Commitlint 配好。倒不是说它能让代码变好,而是它让整个团队的协作信息流变得干净、可追踪。每次看到一串规范的提交记录,就像看到一份写满索引的笔记本,找什么都有迹可循。如果你还没试过,建议从一个小项目开始接上它,哪怕只开三条规则,坚持两周,你会回头感谢自己当初下手够早。