提交信息这东西,平时没人看,可真要回溯问题时,一条条"update"能让你对着 git log 骂十分钟。去年帮团队收拾一个历史项目,我第一件事就是装 commitlint,从安装到配置到让所有人养成习惯,前后折腾了几天。这篇就完整记录一下我是怎么做的,踩过哪些坑,以及最后沉淀下来的推荐配置。不管你是刚接触 commitlint 的新手,还是已经在用但偶尔被诡异问题卡住的开发者,照着这份流程走,基本都能顺利落地。
1. 提交信息失控之前,没人觉得这是问题
1.1 没有规则的 git log 到底有多可怕
先看一个我真实遇到过的提交历史片段:
text复制* 2024-06-03 fix
* 2024-06-01 update
* 2024-05-29 代码提交
* 2024-05-28 修改
* 2024-05-26 add something
如果这是你自己刚写完还没合并的分支,可能还能凭记忆想起每次改了什么。但如果是三个月前的线上故障排查,你需要在海量提交里找出"到底是哪一次改动导致订单金额算错",这种提交信息基本等于没有。我当时面对的就是这么一摊历史,代码 review 的时候 reviewer 只能逐个打开 diff 猜改动意图,效率极低。
后来我把规范后的提交信息拉出来,画风是这样的:
text复制* 2024-07-12 fix(order): 修复满减活动金额精度丢失问题
* 2024-07-11 feat(user): 新增手机号一键登录
* 2024-07-10 docs(readme): 补充本地开发环境搭建步骤
区别是肉眼可见的。type 指明这次提交的性质,scope 指出影响模块,subject 用一句话说明做了什么。别人 review、回溯、写 changelog 时,不需要点开代码就能知道每次提交的目的。
1.2 commitlint 在整条链路里到底管什么
commitlint 是一个专门校验 Git 提交信息的工具,它不检查你的代码质量,不检查有没有语法错误,只检查你写的 commit message 是否符合约定格式。它的设计思路和 ESLint 很像:你定义规则,它负责在提交时拦一道,不满足规则就不允许提交通过。
这里要厘清一个边界。很多人以为装完 commitlint 就能自动规范提交,其实 commitlint 本身只是一个"校验器",它需要三个环节配合才完整:
- commitlint CLI:负责实际执行校验,读取你写好的提交信息然后逐条对照规则;
- 配置文件:告诉它用哪套规则,比如继承官方推荐的 conventional 配置,还是自定义类型范围;
- Git 钩子:解决"什么时候触发校验"的问题,通常是 husky 把 commit-msg 钩子挂上,让每一次 git commit 都自动跑一次校验。
这也回答了经常被问的问题:commitlint 能不能拦 git commit --no-verify?技术上拦不住,--no-verify 本来就是 Git 给用户的逃生门,commitlint 更像是一个"提醒机制",真正想让所有人规范,还得靠习惯、交互工具和 CI 双保险。后面我会详细说怎么搭这套组合拳。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 安装之前先把 commitlint、husky、config 这三兄弟弄明白
2.1 三个包各自扮演什么角色
很多教程直接让执行 npm install -D @commitlint/cli @commitlint/config-conventional,但没解释为什么需要两个包里还有一个 husky。结果很多人装完一头雾水,配置也写不对。
我觉得最直观的理解方式是:commitlint 是一条专门的"交警",但它不会自己跑到马路上执勤,需要有人把它安排在路口;husky 就是那个安排它上岗的人。
用到的包通常有这么几个:
| 包 | 作用 | 必要性 |
|---|---|---|
| @commitlint/cli | 核心命令行工具,负责解析提交信息、执行校验规则 | 必装 |
| @commitlint/config-conventional | 官方预置规则集,基于 Conventional Commits 约定 | 建议装 |
| husky | Git hooks 管理工具,在 commit 时触发 commitlint | 必装 |
其中 @commitlint/config-conventional 里的规则不是随便定的,它对应的是社区广泛使用的 Conventional Commits 规范,格式长这样:
text复制type(scope): subject
type 是提交类型,比如 feat 表示新功能、fix 表示修复缺陷;scope 是影响范围,可选项,比如 login、order;subject 是简短描述。如果想支持破坏性变更,还可以在冒号前加感叹号,比如 feat!(api): 调整接口返回结构。
2.2 版本和 Node 环境要求
commitlint 当前稳定大版本已经到 19.x,husky 主流是 v9,两个工具对 Node 版本都有要求。如果 Node 还是老旧的 14、16,装最新版大概率会在安装阶段或运行阶段报错。我在本机统一用 Node 18 LTS 以上版本,实测下来比较省心。
版本兼容是最容易被忽略的坑。husky v4 和 v9 的配置方式完全是两代东西,v4 时代很多人习惯在 package.json 里维护 husky 配置项:
json复制{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}
这套写法在 husky v9 已经不再生效。如果从网上复制了旧教程,会发现钩子怎么都不触发。我在 5.1 节会展开讲怎么排查这类历史遗留问题。
安装命令建议一次性装齐:
bash复制npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
这里特意用 --save-dev,是因为这些工具只服务于开发阶段,没有理由进入生产依赖。团队其他人 npm install 后会自动安装到本地,不需要单独做初始化。
2.3 为什么我不建议用全局安装
有一个常见疑问:既然是命令行工具,为什么不用 npm install -g @commitlint/cli?我在早期一个项目里图省事试过全局安装,后来发现两个麻烦。第一,版本不跟着项目走,有人本机是 17.x,有人升级到 19.x,规则解析行为可能不一样,同一段提交在不同机器上结果不一致。第二,CI 环境通常不会装全局包,到流水线里还是要回到 npx 或直接调 node_modules/.bin/commitlint。
所以结论很明确:项目本地安装,配合 npx 使用。这样 lock 文件锁住了版本,任何人都能拿到一致的工具链。
3. 从零到一给项目接上 commitlint 的完整实操
3.1 初始化项目并安装依赖
我先用一个空目录演示完整流程,实际操作时你可以把同样的步骤套到现有项目上。
bash复制mkdir commitlint-demo
cd commitlint-demo
git init
npm init -y
然后安装依赖:
bash复制npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
这里要注意一点:husky 的安装脚本会在 install 阶段尝试把 Git 钩子目录初始化到 .husky 文件夹。如果项目还没有 git init,或者当前目录不在 Git 仓库里,husky 安装过程可能不会生成钩子。所以顺序一定是先 git init 再安装,不要倒过来。
3.2 用 husky 注册 commit-msg 钩子
husky v9 提供了一个比较友好的初始化命令:
bash复制npx husky init
执行后,husky 会在项目根目录生成 .husky/ 文件夹,里面默认有一个 pre-commit 示例文件,内容是运行 npm test。这个 hooks 目录才是 husky v9 真正读取钩子的位置,不再是 package.json。
接下来我们要把默认的 pre-commit 示例改成真正需要的 commit-msg 钩子。新建一个文件 .husky/commit-msg,写入:
bash复制npx --no -- commitlint --edit "$1"
我习惯用下面的命令直接生成:
bash复制echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
chmod +x .husky/commit-msg
简单解释下这几个参数。--edit "$1" 是告诉 commitlint 去读取 Git 传入的提交信息临时文件,也就是 .git/COMMIT_EDITMSG。$1 是 commit-msg 钩子自带参数,Git 会把它替换成提交信息文件路径。前面的 --no 是 npx 的参数,意思是禁止 npx 在找不到命令时自动从网络下载安装,这样如果工具没装好会立刻报错,不会静默通过或者无限等待。
3.3 写第一份配置文件
commitlint 需要知道用哪套规则,所以项目根目录要有配置文件。官方支持多种文件名格式,包括 .commitlintrc.json、.commitlintrc.cjs、commitlint.config.js 等。具体用哪种取决于你的项目是否开启了 ESM 模式,这里我先用最省心的 commitlint.config.cjs:
javascript复制// commitlint.config.cjs
module.exports = {
extends: ['@commitlint/config-conventional']
};
extends 的作用是继承配置,和 ESLint 的 extends 概念一样。@commitlint/config-conventional 会把默认的类型枚举、大小写限制、长度限制等都带进来。大多数项目用它作为起点就够了,后面想微调再加 rules。
如果你的项目 package.json 里没有 "type": "module",也可以把文件写成 commitlint.config.js,两种写法等价。如果项目里已经有 "type": "module",那 commitlint.config.js 会被当成 ESM 模块解析,这时候 module.exports 就会报错,推荐直接用 .cjs 后缀减少踩坑概率。这个问题我在后面排错部分还会详细说。
3.4 实际提交验证:一眼看清通过和拦截的区别
现在做一次完整的验证。先随便创建一个文件并加入暂存区:
bash复制echo "# demo" > README.md
git add .
然后故意提交一条不合规范的 message:
bash复制git commit -m "add docs"
这时候 commit-msg 钩子会被触发,终端会输出类似下面的信息:
text复制⧗ input: add docs
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 2 warnings
看到这个拦截提示说明整套链路已经通了。它告诉你两条规则失败:type 为空、subject 也为空。因为 add 不在 conventional 约定的 type 枚举里,commitlint 根本没法把它识别成有效类型,于是连带着 subject 也解析不出来。
接下来改成规范写法:
bash复制git commit -m "docs: add README"
这次就能顺利提交。如果你也想查看最近一次提交是否合规,可以手动执行:
bash复制npx --no -- commitlint --from HEAD~1 --to HEAD
这条命令在后续排查和 CI 里非常有用。
4. 不要只会套模板:规则字段到底怎么写才放心
4.1 一条规则为何是三个元素
commitlint 的每一条规则本质上就是一个三元组数组:
javascript复制rules: {
'rule-name': [Level, Applicable, Value]
}
三个位置的含义分别是:
- Level:
0表示关闭,1表示警告但不会阻止提交,2表示错误并阻止提交; - Applicable:
always或never,表示这条规则在什么条件下生效; - Value:规则对应的参数,比如
100、'lower-case'、枚举数组等。
用一个具体的例子来看:
javascript复制'header-max-length': [2, 'always', 100]
意思是:标题最大长度限制为 100,始终校验,超长就报错且阻止提交。再比如:
javascript复制'subject-full-stop': [2, 'never', '.']
意思是:subject 结尾永远不允许出现句号。这里 never 表示"任何时候都不允许命中后面的值",和 always 正好相反。
很多新手会以为 always / never 是开关,其实它是约束条件。理解这一点,看官方规则文档会顺畅很多。
4.2 config-conventional 默认规则逐条过一遍
继承 @commitlint/config-conventional 之后,默认生效的核心规则大致如下:
| 规则名 | 默认配置含义 |
|---|---|
| type-enum | type 只能是 feat、fix、docs、style、refactor、perf、test、build、ci、chore、revert 等枚举值 |
| type-empty | type 不能为空 |
| type-case | type 必须是小写 |
| subject-empty | subject 不能为空 |
| subject-full-stop | subject 结尾不能是句号 |
| subject-case | subject 不能是 sentence-case、start-case、pascal-case 等 |
| header-max-length | header 总长度不能超过 100 |
| body-max-line-length | body 每行不能超过 100 |
| footer-max-line-length | footer 每行不能超过 100 |
| body-leading-blank | body 之前必须有一个空行 |
| footer-leading-blank | footer 之前必须有一个空行 |
实际体验中,最常被触发的就是 type-enum、type-empty、subject-empty、subject-case 这几个。比如 Add Button 这种提交会死在 type-case 上,因为 Add 不是小写;feat: 增加按钮. 会死在 subject-full-stop 上,因为结尾多了个句号。
4.3 scope 必填、body 必填这类需求怎么定制
不同团队对提交信息的严格程度不一样。有的团队觉得 scope 可选项无所谓,有的团队希望所有提交都标注影响模块,方便生成 changelog 时按模块归类。想要让 scope 必填,可以加一条:
javascript复制module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'scope-empty': [2, 'never']
}
};
scope-empty 默认没有开启,加这条之后,所有提交都必须写成类似 feat(user): xxx 的格式,如果写成 feat: xxx 直接被拦下。
如果你希望每个提交的正文也不能缺失,同理可以加:
javascript复制'body-empty': [2, 'never']
不过我要提醒一句:规则越严,开发者抵触心理越强。scope 必填还相对合理,body 必填会让很多本来一句话能把事说清的小修改变得很痛苦,最后大家往往用 feat(x): xxx\n 这种凑合方式绕过。我在实践里一般不强制 body 必填,而是鼓励大家在 body 里写为什么改、影响是什么,不写也能提交。
增删 type 枚举也是高频定制需求。比如有人喜欢用 ui 表示样式与界面调整,但默认枚举里没有它,此时需要覆盖枚举:
javascript复制module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert', 'ui'
]]
}
};
这里的关键是:给整个 type-enum 数组重新赋值时,一定要把原来需要的类型都写上,而不是只写新增项。规则校验时是拿你的提交类型去数组里找,找不到就报错,所以数组不全等于把合法类型也误杀了。
4.4 默认解析不够用时的自定义 parser
常规提交的格式满足不了全部场景,比如某些项目需要在类型前加版本号,或者习惯用破折号而不是冒号分隔。这时候可以自定义 parserPreset。
先看一个例子,让 commitlint 支持类似 release(1.2.0): 发布新版本 这样的提交信息:
javascript复制module.exports = {
extends: ['@commitlint/config-conventional'],
parserPreset: {
parserOpts: {
headerPattern: /^(\w+)(?:\(([\w$-]+)\))?!?: (.+)$/,
breakingHeaderPattern: /^(\w+)(?:\(([\w$-]+)\))?!?: (.+)$/,
headerCorrespondence: ['type', 'scope', 'subject']
}
}
};
headerPattern 是正则,把 header 拆成 type、scope、subject 三段;headerCorrespondence 声明拆分结果对应到提交消息结构里的哪个字段。这样规则引擎才能识别出 type 是 release,scope 是 1.2.0,subject 是 发布新版本。
需要提醒的是,写自定义正则时务必考虑破坏性变更标记 !,否则像 feat!(api): xxx 这类提交解析时会错位。如果没有把握,我建议尽量沿用默认 parser,只在确有需要的小范围项目里自定义,省得把后续升级配置的成本都抬高了。
5. 我踩过的坑和一套可复用的排查路径
5.1 钩子没生效:别急着怪 commitlint,先从五层逐级查
最常见的现象是:配置文件写了,依赖也装了,但不管提交什么格式都能通过,像是 commitlint 根本不存在。这种问题我见过太多次,每次排查的思路基本一致,按下面这个顺序来。
第一层:commit-msg 钩子文件到底存不存在。
bash复制ls -la .husky/
确认有没有 commit-msg 文件。如果只有 pre-commit 而没有 commit-msg,说明你漏了 3.2 里手工创建的那一步。很多人执行完 npx husky init 就以为完事了,但 init 默认只生成 pre-commit 示例。
第二层:Git 实际使用的 hooks 路径是不是被改过。
bash复制git config core.hooksPath
正常情况下不会输出内容或输出 .husky。如果输出的是别的路径,比如 .githooks 或某个全局路径,说明有其他工具或手动设置篡改了 hooksPath,husky 的钩子自然不触发。解决办法是把路径指回 .husky,或者去掉设置:
bash复制git config core.hooksPath .husky
第三层:手动执行钩子文件看有没有报错。
bash复制sh .husky/commit-msg .git/COMMIT_EDITMSG
如果这一步报 npx: command not found,可能是 shell 环境变量问题;如果报 commitlint 找不到,就回到依赖是否安装成功上检查。
第四层:hook 文件里的命令有没有写对。
最常见的错误是漏了 --edit 参数,或者把 $1 写成了别的变量,比如复制的旧教程里写 $GIT_PARAMS。husky v9 的 commit-msg 参数就是 $1,没有其他名字。
第五层:确认 commitlint 自身能手动校验出问题。
bash复制echo "add docs" | npx --no -- commitlint
echo "docs: add docs" | npx --no -- commitlint
第二条如果能通过而第一条被拦截,说明 commitlint 本身工作正常,问题只出在钩子触发链路。如果这两条结果都是通过,那要怀疑配置文件没被正确加载,去排查下一节说的文件格式问题。
5.2 ESM 和 CJS 配置加载失败:几行字引发的灵异事件
有段时间 commitlint 报错信息非常隐晦,报的是"Failed to load config",但 config 文件明明就在根目录。后来定位到是 package.json 里的模块类型冲突。
如果 package.json 设置了 "type": "module",项目里的 .js 文件默认按 ESM 解析。此时如果你用的是 commitlint.config.js 而且里面写的是:
javascript复制module.exports = { extends: ['@commitlint/config-conventional'] };
Node 解析时会直接报错,因为 ESM 模式下没有 module 这个全局对象。解决办法有两个:
一是把 config 文件改成 .cjs 后缀,让 Node 明确按 CommonJS 处理:
bash复制mv commitlint.config.js commitlint.config.cjs
二是在 config 文件里改用 ESM 的 export 语法:
javascript复制export default {
extends: ['@commitlint/config-conventional']
};
类似地,如果项目不在 ESM 模式而用了 .mjs,也需要匹配对应文件。我的建议是统一用 commitlint.config.cjs,这样无论项目有没有 "type": "module" 都能稳定加载,少一个变量。
5.3 husky 升级后的历史配置残留问题
很多人是带着 husky v4 的旧项目升级过来的。老项目里 package.json 通常有类似这样的配置:
json复制{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}
husky v9 完全不读这段配置,就算你把它删掉也不会影响钩子,因为它只认 .husky 目录下的文件。可问题恰恰出在这里:如果你只在 package.json 里改配置而不去 .husky 目录里建文件,钩子就会神秘失效。
我处理升级项目时,标准做法是先清掉 package.json 里的 husky 配置块,再执行 npx husky init,然后把需要的钩子全部手动建一遍,并且 git 提交一次确认 .husky/ 目录被纳入版本管理。注意,如果 .husky 目录因为 .gitignore 规则被忽略了,其他成员拉代码时拿不到钩子文件,又会回到"只有你机器上能用"的尴尬局面。
5.4 Windows、换行和权限:三个本地化小问题
Windows 下用 Git Bash 或 WSL 开发时,husky 的钩子脚本要求有执行权限。如果创建出来的 .husky/commit-msg 文件没有 x 权限,提交时会报 cannot execute。建议创建后手动执行:
bash复制chmod +x .husky/commit-msg
同时注意,别用记事本之类工具把文件保存成带 BOM 的 UTF-8 格式,shell 解释器可能把 BOM 当作命令的一部分,进而报 command not found。最好统一用编辑器默认的 UTF-8 无 BOM 格式,或者直接通过命令行 echo 创建。
还有一个容易踩的坑是换行符。如果项目的 .gitattributes 里有强制转换规则,可能会把钩子文件的行尾从 LF 转成 CRLF,导致 shell 执行时出问题。建议在 .gitattributes 里加上这一行:
text复制*.husky/* text eol=lf
或者对这个文件统一保持 LF。
5.5 老项目存量提交过多时怎么平滑落地
老项目落地的另一个现实问题是:历史提交可能有一大半都不规范。commitlint 只拦截新提交,不影响历史,这一点倒不用担心。真正要担心的是引入之后第一次提交就失败,团队的挫败感会不会太强。
我的做法是分三步走。第一步,先只装工具和配置,不强制所有人立刻改变,观察开发者会被哪些规则频繁拦截。第二步,根据真实报错调整规则,比如把一些不合理的限制放开,或者增加团队常用类型。第三步,等大家对格式熟悉了,再把交互式提交工具和 CI 校验接上。与其一上来铺满规则让所有人反感,不如先让工具低声提醒,再逐步卡紧。
如果只是想了解历史提交到底有多少不合规,可以用这条命令快速扫描最近 N 条:
bash复制npx --no -- commitlint --from HEAD~50 --to HEAD
它会把范围里每条提交依次校验一遍。我经常用这个结果给团队展示"不规范比例有多高",比口头讲半天更有效。
6. 让规范真正落地:交互提交工具和 CI 兜底
6.1 用 cz 工具把背规则变成点菜单
commitlint 这类校验工具有一个天然的矛盾:规则越细,开发者越要记。type 一共十几种,scope 填什么,subject 怎么措辞,光靠脑子记很容易出错。我工程里的解法是配合交互式提交工具,让工具替人记住规则。
比较经典的一套是 commitizen + cz-conventional-changelog:
bash复制npm install --save-dev commitizen cz-conventional-changelog
npx commitizen init cz-conventional-changelog --save-dev --save-exact
安装完成后,不再直接执行 git commit,而是执行:
bash复制npx git-cz
这时终端会变成问答式界面,先让你选 type,再填 scope,再写 subject,最后写 body。工具生成出来的 message 本身就满足 conventional 格式,commitlint 校验自然很容易通过。
这种工具最大的价值不是省几秒输入时间,而是把工具链打通:即使开发者刚开始不熟悉 type 类别,也能看到每个类型对应的解释说明,很快就形成肌肉记忆。
6.2 把 commitlint 放进 CI,拦住绕过本地的坏提交
本地钩子终究是"君子协定",开发者可以手动 --no-verify 绕过去,有时候本地开发分支一大堆临时提交,也不想费时间规范。所以在 CI 里再校验一层,是保证主干提交质量的关键手段。
以 GitHub Actions 为例,可以在 PR 工作流里加一个 job:
yaml复制- name: checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: setup-node
uses: actions/setup-node@v4
with:
node-version: 20
- name: install
run: npm ci
- name: commitlint
run: npx --no -- commitlint --from origin/main --to HEAD
这段配置有两个细节很关键。第一,fetch-depth: 0 表示拉取完整历史,否则默认浅克隆只包含最近一次提交,commitlint 无法比较 origin/main 到 HEAD 这个范围。第二,校验范围选择的是 PR 里新增的提交,而不是全部提交,否则会把 target 分支上已经存在的历史也校验一遍。
如果你的提交里只有一条,也可以简化成:
bash复制npx --no -- commitlint --from HEAD~1 --to HEAD
在 GitLab CI、Jenkins 等环境里逻辑相同,核心思路都是先拉全量代码,再安装依赖,再执行范围校验。
6.3 还有一个容易被忽略的文件:根目录的 commitlint.config 也会影响 IDE 插件
前端生态很发达,很多人其实不用 commitizen,而是用 VS Code 的 Conventional Commits 这类插件。这类插件普遍会读取项目的 commitlint 配置,尤其是自定义的 type 枚举和 scope 枚举。也就是说,你把 commitlint.config.cjs 里的 type-enum 改了之后,插件界面里的下拉选项也会跟着变,不需要额外配置一份。
这也是
