如果你的团队已经接入了 Husky,你大概率见过这两个钩子文件——.husky/pre-commit和.husky/commit-msg。pre-commit 里跑npx lint-staged,commit-msg 里写一句npx --no -- commitlint --edit $1。这几乎是前端脚手架生成的标配。
但坦白说,我经手的几十个仓库里,commit-msg 是那个“存在但没人真正懂”的钩子。大家知道它校验提交信息,可当它真的拦下一次提交时,团队里往往没人能解释清楚:$1到底是什么?为什么git merge之后它也会蹦出来?为什么同一个规则在 Windows 上正常、在同事的 Mac 上就挂?这篇文章把我这些年折腾 Husky 和 commit-msg 的经验一次讲清楚,包括钩子的触发机制、手写校验脚本的完整思路、接入 commitlint 的配置取舍,以及我踩过的那些真实翻车现场。适合已经用上 husky、但想更深入理解提交信息校验原理的前端和后端同学。
1. 提交信息才是团队里最容易被低估的长期资产
很多团队对 commit message 的态度是“能写就行”“一行随便带过”。但你打开git log看看,提交信息在代码被重构、模块被替换之后依然躺在历史里,它是整个仓库的审计线索、回滚依据,也是 changelog 生成的唯一原料。
代码风格错了可以靠 lint 和 review 兜底,命名不统一可以由 IDE 辅助,唯独提交信息没有“编译器”。它一旦进历史,再想批量修就是另一场灾难。我见过一个项目,日志里一半是update、fix bug、aaa,到了要定位某个功能是什么时候引入的时候,我只能靠git blame一行行翻,那个痛苦我现在都记得。
所以我会跟团队说:pre-commit 拦的是“代码质量”,commit-msg 拦的是“沟通质量”。前者保证程序能跑,后者保证人还能看懂。Husky 在这里做的事情,本质上是在提交动作发生前加一道“约定执行器”,而 commit-msg 是这道闸门的最后一关。
1.1 为什么大多数人对 commit-msg 只停留在“知道”
脚手架生成的 commit-msg 文件太短了,短到没人愿意认真看。它通常就一行:
npx --no -- commitlint --edit $1很多人的理解止步于此:commit-msg 就是跑一下 commitlint。但这一行里的信息量其实非常大:
npx --no是刻意不用 npx 的临时下载能力,强制走项目的本地依赖;commitlint --edit是让 commitlint 从一个文件中读取提交信息;$1是 git 传给 commit-msg 钩子的第一个参数,指向一个临时文件。
如果你不清楚这三个细节,一旦出现“本地能用、别人机器上挂了”的情况,排查起来会非常被动。更夸张的是,我见过有人手滑把整个 hook 改成commitlint --from HEAD~1 --to HEAD,跑当然能跑,但校验的根本不是“这一次要提交的内容”,而是“上一次 commit 的内容”,完全偏离了钩子的设计意图。
2. commit-msg 钩子运行的底层机制:触发时机、参数与返回值
想真正掌控 commit-msg,得先把它放进 git 的钩子模型里看。
2.1 git hooks 与 Husky 的“代理”逻辑
git 原生的 hooks 位于.git/hooks/目录下,里面有一堆以.sample结尾的示例脚本,包括commit-msg.sample、pre-commit.sample、post-commit.sample等。git 每次执行到对应动作时,会去这个目录里找同名可执行文件,有就跑,没有就跳过。
Husky 做的事情就是“代理”。它不修改你的业务脚本,而是把 git 的钩子入口统一指向一个由它管理的目录。在新版本里,husky install会执行一条关键配置:
git config core.hooksPath .husky这条命令之后,git 不再去.git/hooks/找钩子,而是去.husky/找。所以你在仓库根目录看到的那一排.husky/pre-commit、.husky/commit-msg,本质上就是让 git 更可控地执行团队约定的一组脚本。
这里顺带提一下版本的跃迁。Husky 4 时代,钩子配置写在package.json里的husky.hooks下,安装依赖时自动注册;Husky 9 时代,它只认根目录的.husky目录,初始化命令也变成了npx husky init,钩子里拿 git 参数从$HUSKY_GIT_PARAMS换成了$1。如果你看的旧教程还在教HUSKY_GIT_PARAMS,赶紧把手里的配置升级一下。
2.2 commit-msg 触发的时机与 COMMIT_EDITMSG 文件
git 一次常规提交的钩子顺序大致是:pre-commit→prepare-commit-msg→commit-msg→post-commit。pre-commit跑的时候,提交信息还不存在;用户要么通过-m传了参数,要么在编辑器里写完了信息并保存,之后 git 才会调用commit-msg。
这意味着commit-msg拿到的是“最终版”提交信息。git 会把本次提交的信息写到一个临时文件里,路径通常是.git/COMMIT_EDITMSG,然后把这个路径作为第一个参数传给 commit-msg 钩子。所以:
- 用
git commit -m "feat: 新增登录"时,文件里是feat: 新增登录加上一些以#开头的注释; - 用编辑器提交时,文件里是模板注释加用户输入内容;
- 用
git commit -m "header" -m "body"时,文件里会有两个段落。
这也是为什么 commitlint 用--edit而不是把提交信息当字符串传进去——读文件是最可靠的方式。手写校验脚本时同样要遵循这个约定,用$1定位文件,再从里面解析内容。
2.3 退出码决定提交是否成功
钩子脚本的本质是一个可执行程序。git 只关心它的退出码:返回0,提交继续;返回非0,提交中止。就这么简单。
这条规则带来一个非常实用的推论:你不需要依赖 commitlint 才能做校验。只要在脚本里能“读文件 + 判断 + 退出”,任何语言都能写一个 commit-msg 钩子。团队完全可以只用一个几十行的 Shell 脚本,而不是引入整个 Node 依赖链。
另外记住一点,commit-msg的阶段在pre-commit之后。lint-staged 在pre-commit里改了文件然后重新git add,这些动作做完,提交内容已经确定,最后才轮到commit-msg把关信息格式。所以不要试图在pre-commit里校验提交信息,那个阶段你根本拿不到完整的提交文本。
3. 不装 commitlint,手写一个属于自己的 commit-msg 校验脚本
先声明我的观点:commitlint 很好,但它不是唯一解。有些仓库规则非常简单,或者团队对提交格式有特殊癖好(比如必须带需求单号),这时候一套自定义脚本反而更透明、更容易维护。
3.1 自定义校验脚本的完整骨架
在.husky/commit-msg文件里放下面这段脚本,注意 shebang 用sh而不是bash,因为 git 在 Windows Git Bash 和各种服务器上的默认解释器并不完全一致,POSIX 兼容写法最稳。
#!/usr/bin/env sh # .husky/commit-msg # 自定义提交信息校验脚本,不依赖 commitlint commit_msg_file="$1" # 提取第一行非注释、非空的内容,作为提交信息的 header header=$(grep -vE '^#' "$commit_msg_file" | grep -vE '^\s*$' | head -n 1 | tr -d '\r') # 校验 header 格式:type(scope): subject if ! echo "$header" | grep -Eq '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+'; then echo "" echo "commit message 不符合团队规范" echo "期望格式: <type>(<scope>): <subject>" echo "例如: feat(user): 增加用户注册功能" echo "" exit 1 fi # 校验 header 长度,避免一屏看不完 header_length=$(echo "$header" | awk '{ print length($0) }') if [ "$header_length" -gt 100 ]; then echo "提交信息 header 长度超过 100 字符,请精简" exit 1 fi # 校验 body 每行长度,Git 最佳实践建议 72 字符左右,这里放宽到 100 if awk 'length($0) > 100 { print "第 " NR " 行超过 100 字符: " $0; bad=1 } END { exit bad }' "$commit_msg_file"; then : else echo "提交信息 body 中存在超过 100 字符的行" exit 1 fi exit 0这段脚本做了三件事:提取 header、校验格式、校验行长。删掉注释后大概 30 行,挂在.husky/commit-msg下就是一个完整的钩子。
3.2 为什么第一步要“剔除注释、取第一行”
很多人手写脚本时直接head -n 1 "$1",然后校验失败。原因很简单:当用户用编辑器提交时,COMMIT_EDITMSG文件的第一行可能是 git 模板里的注释,比如:
# Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit.如果不把#开头的行过滤掉,你校验的就变成 git 自己的提示文本,而不是用户写的提交主题。我在这个细节上吃过亏,后来统一改用“先grep -vE '^#'、再grep -vE '^\s*$'、最后取第一行”的顺序,才把各种编辑器的差异抹平。
3.3 规则扩展:scope、Breaking Change 与 footer 的校验
基础格式跑通以后,如果你的团队还需要更细的规则,可以在同一套脚本里继续加分支判断。
- 强制 scope:让
(scope)变成必填项,正则改成^(feat|fix|...)\([a-z-]+\): .+。适合按模块管理的仓库。 - 解析 Breaking Change:在正文里识别
BREAKING CHANGE:前缀,如果存在,可以要求 header 必须带!标记,避免破坏性变更被悄悄提交。 - footer 关联单号:很多内部项目使用
JIRA: PROJ-123或Issue: #456作为 footer,可以在脚本里用一行grep -Eq '^(JIRA|Issue):'判断是否存在关联信息。 - 自定义 type 清单:直接把
type-enum从 commitlint 规则里搬出来,在正则里列一个自己的白名单。
手写脚本有一个天然优势:错误提示可以完全贴着自己团队的上下文来写。比如“请检查你的 type 是否在白名单里,当前可选:feat/fix/docs/style/refactor/perf/test/build/ci/chore/revert”,这句话比 commitlint 默认的英文提示更能在半睡半醒的状态下让人看懂。
4. 接入 commitlint 的完整配置:从装包到规则取舍
如果团队规模不小、提交规范想做成行业通用格式,直接上 commitlint 更省心。它的规则体系在处理边界情况上比我手写脚本完整得多,比如对git revert生成的Revert "..."提交、对中文提交信息、对脚注解析,都有现成的解析器。下面是一套我在实际项目中反复验证过的接入方式。
4.1 装包与初始化
项目根目录执行:
pnpm add -D @commitlint/cli @commitlint/config-conventional npx husky init npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'npx husky add第一次跑会创建.husky/commit-msg文件。注意加号命令里必须用单引号包裹$1,如果用双引号,Shell 会在当前终端里把$1展开成空,写进文件的内容就变成了npx --no -- commitlint --edit,钩子运行时拿不到提交信息文件路径。
--no参数是很多人容易忽略的点。它的作用是禁止 npx 在本地没有依赖时临时联网安装。加了--no之后,如果node_modules/.bin/commitlint不存在,命令会直接失败,而不是静默下载一个临时版。这保证了“你本地校验通过的规则,在别人机器上也一定是同一个版本的 commitlint 在跑”。
4.2 配置文件的三种写法与那个经典的 type: module 坑
commitlint 默认找commitlint.config.js,但对 ES Module 项目来说,这个.js文件会被 Node 当作 ESM 解析。如果你在package.json里写了"type": "module",又用module.exports写配置文件,跑起来就会报module is not defined。
排查链路我走了一次怎么都记不错:先看package.json有没有"type": "module",有的话选择二选一——
把配置文件改成.cjs后缀,内容保持不变:
// commitlint.config.cjs module.exports = { extends: ['@commitlint/config-conventional'], };或者直接把配置文件改成 ESM 语法:
// commitlint.config.js export default { extends: ['@commitlint/config-conventional'], };我个人更推荐.cjs。原因很现实:老项目里的脚本、其他工具链(比如一些 CLI)可能还在用 CommonJS 读配置文件,.cjs的兼容面最广。
4.3 规则定制:从照搬默认到贴合自身
@commitlint/config-conventional的默认规则覆盖了社区最主流的 Angular 规范,但直接拿过来用往往需要微调。下面这套是我在多个团队落地的基线,兼顾了规范性和可操作性:
// commitlint.config.cjs module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert' ]], 'subject-case': [0], 'header-max-length': [2, 'always', 100], 'body-max-line-length': [2, 'always', 100], 'footer-max-line-length': [2, 'always', 100] } };重点说两个规则。
type-enum是提交规范的“宪法”,所有 type 的白名单在这里定义。不要照抄大厂那一长串,你的团队用不到wip、init、config之类的话,白名单越长,大家越懒得选,最后全都在用chore混过去。五到八个常用 type 足够,我把build、ci、revert都放进去了,是因为这些确实会偶发出现。
subject-case在默认配置里是开的,它检查 subject 不应该以某些风格出现。实际问题在于很多输入法或者编辑器会自动把首字母大写,用户明明输入的是fix: fix bug,编辑器改成fix: Fix bug,commitlint 就报错。这种报错给用户的第一反应是“工具坏了”。我直接把subject-case设成[0]关掉,换取的上手体验提升非常明显。
4.4--edit $1的语义与手动检测方法
.husky/commit-msg里的命令:
npx --no -- commitlint --edit "$1"意思是告诉 commitlint:去读$1指向的那个文件,把内容解析成提交信息并校验。理解这一点后,你在本地想手动复现一次校验,可以直接对最近一次提交的信息做检查:
npx --no -- commitlint --edit <(git log -1 --format=%B)<( ... )这种进程替换只有在 bash 里可用,做排查用一下没问题。更常见的排查方式是直接看文件内容:
cat .git/COMMIT_EDITMSG然后手动执行:
npx --no -- commitlint --edit .git/COMMIT_EDITMSG这样可以快速判断到底是钩子脚本出问题,还是 commitlint 规则本身拒绝了内容。
5. 踩坑实录:merge、amend、CRLF 与 CI 环境的真实翻车现场
技术方案看得再多,不如在真实环境里被坑一次。下面几个案例是我和不同团队在接入 commit-msg 后实际撞上的,每一个都让我对钩子机制的理解更深了一档。
5.1 Windows 上正则莫名失败:CRLF 的完整排查链路
现象描述:同事在 Windows 上提交feat: 新增用户中心,自定义脚本报“不符合规范”。他觉得很奇怪,格式明明是对的,中文和英文都试了,就是过不去。
第一反应是用 commitlint 手动校验,报同样的错。然后我把COMMIT_EDITMSG的内容打印出来,视觉上完全正常。接下来用sed -n '1l' .git/COMMIT_EDITMSG查看不可见字符——这一招非常关键,sed的l命令会把一行结尾的不可见字符显示成$。输出结果里,第一行末尾有一个\r。
根因一下就清楚了:Windows 上 git 默认开启了core.autocrlf,检出文件时把LF替换成了CRLF。hook 读取COMMIT_EDITMSG时拿到的是带\r的文本,而脚本正则里的$锚点匹配的是\n前面的位置,残留下来的\r导致整个正则匹配失败。
解决方式分两层。外层是规范团队配置:在.gitattributes里显式声明提交信息相关的文件保持LF,或者让 Windows 同学把core.autocrlf设为input。内层是最稳的自我修复:所有手写校验脚本在过滤行时都加一步tr -d '\r'。上面第三部分给出的脚手架已经包含了这一步,凡是没加这一步的 Windows 用户,几乎都会踩同一个坑。
5.2 merge commit 被误杀:一次由 pull 引发的“扑街”
某次团队代码合并,一位同事执行git pull之后,git 自动生成了一个 merge commit,提交信息是:
Merge remote-tracking branch 'origin/dev' into main结果 commit-msg 钩子果断退出码 1,把他卡在原地。原因是常规的 commitlint 规则只认type(scope): subject格式,Merge ...根本不在 type 白名单里。
这个坑的破坏力在于“政治正确”——当你只是想同步代码,却被自己的提交规范拦死,第一反应往往是骂工具,而不是反思规则。
长期解法是尽量用git pull --rebase,减少自动生成的 merge commit。短期解法是让 commitlint 对 merge 提交放行。具体做法是在配置文件里加一条ignores函数,识别以Merge开头的提交直接跳过:
module.exports = { extends: ['@commitlint/config-conventional'], ignores: [(commit) => /^Merge\s/.test(commit)], };这个坑提醒我:任何校验规则都要想清楚“它拦下的对象里,有没有合法用户”。工具的目的是拦错,不是拦人。
5.3 amend 与 --no-verify:钩子不是安全锁
很多人不知道git commit --amend会重新触发 commit-msg。如果你第一次提交用了--no-verify逃过校验,那么 amend 的时候大概率会被钩子抓住,逼你把信息改成合规格式。从流程设计角度看,这其实是个不错的兜底。
但我也要强调一个现实:--no-verify是万能后的门。任何 hook 都能被它跳过,husky 不是安全锁,它拦的是“不小心”,不是“故意”。团队里真正能约束提交规范的,只有 code review 和 CI 侧的二次检查。我在 CI 流水线里会额外加一个 step,对 PR 的所有提交做范围校验:
npx --no -- commitlint --from HEAD~$(git rev-list --count HEAD^..origin/main) --to HEAD这样即使本地有人绕过 hook,合并前也会被 CI 拦下来。
5.4 CI 环境里 hook 静默消失
我在一个自动化发布项目里反复碰到一种现象:本地提交规范跑得好好的,一到 CI 里的机器人提交 commit,提交信息就放飞自我。排查后才发现,CI 里安装依赖用了npm ci --ignore-scripts,这个命令会跳过 npm 的prepare生命周期脚本,而 husky 正是在prepare阶段完成core.hooksPath配置的。
钩子根本不存在,自然也不会有人去校验。对此有三个层面的措施:
- 安装依赖时不要全局
--ignore-scripts,至少让它执行 husky 相关脚本; - 机器人在 CI 里生成的提交,在 commit 前显式跑一次校验命令;
- 或者干脆接受“CI 自动提交不经过本地钩子”的现实,用 GitHub Actions 里现成的 commitlint 检查步骤在 PR 维度兜底。
额外提一个细节:HUSKY=0环境变量可以临时关闭全部 husky 钩子。在那种“只想快速提交一份临时代码,不想被任何规则打扰”的场景里,它比--no-verify更语义化。但同样地,它只能用于本地救急,不该成为 CI 的默认配置。
6. 团队提交规范落地的最后一公里
提交规范能不能真正活下去,不取决于规则多严谨,而取决于大家“打一条合规提交信息”的成本有多低。我做过几次复盘,发现凡是规范推行不下去的团队,问题都出在“靠人脑子记格式”上。
6.1 用 commitizen 和 cz-customizable 把提交变成交互式问答
与其让大家背 type 清单,不如把提交命令替换成交互式工具:
pnpm add -D commitizen cz-customizable在package.json里声明:
"config": { "commitizen": { "path": "cz-customizable" } }再放一份.cz-config.js,定义中文化的 type 提示:
module.exports = { types: [ { value: 'feat', name: 'feat: 新功能' }, { value: 'fix', name: 'fix: 修复缺陷' }, { value: 'docs', name: 'docs: 文档变更' }, { value: 'style', name: 'style: 代码格式调整' }, { value: 'refactor', name: 'refactor: 重构' }, { value: 'perf', name: 'perf: 性能优化' }, { value: 'test', name: 'test: 测试相关' }, { value: 'chore', name: 'chore: 构建/工具链杂项' } ], scopes: ['user', 'order', 'pay', 'common'], allowCustomScopes: true, subjectLimit: 100 };之后团队用它代替原始的git commit,commitlint 仍然照常校验,但用户从“背规则”变成了“做选择题”,抵触情绪会小很多。
6.2 一层更轻量的保障:提交模板
如果连交互式工具都不想引入,还有一个非常轻的落地方式:提交模板。在仓库根目录放一个.gitmessage.txt:
<type>(<scope>): <subject> <body> <footer>然后让每个成员执行一次:
git config --local commit.template .gitmessage.txt之后每次git commit打开编辑器,自动带出模板骨架,用户只需填空。配合 commit-msg 钩子的校验,这基本构成了一个零依赖的规范闭环。
6.3 规范提交信息带来的额外回报:changelog 可以自动生成
提交格式一旦稳定,很多工具链会自动受益。standard-version可以直接根据feat、fix、BREAKING CHANGE这些标记自动 bump 版本号并生成 CHANGELOG.md。git bisect查找回归也能靠类型标记快速定位到“引入新功能”的那次提交。我后来在项目里接上 changelog 自动生成以后,团队对提交规范的态度从“麻烦”变成了“有回报”,这是个很微妙但是很关键的转变。
关于 commit-msg 配置,如果你之前只把它当成脚手架生成的一行咒语,我建议你花十分钟拆开看一遍:删掉.husky/commit-msg里的命令,改成先cat "$1"再看它到底长什么样。理解这个文件、理解$1指向的内容,你对 git 提交流程的掌控感会明显不一样。我这些年最大的体会是:提交信息规范不是一个可以一步到位的工程,先让 80% 的提交符合简单规则,再慢慢收紧,永远比一开始设计一套完美规则然后无人执行要好。