1. 这个报错到底在拦什么:husky 与 pre-commit 的角色拆解
第一次看到husky > pre-commit这行提示的时候,很多人第一反应是“git 坏了”或者“仓库出问题了”,其实恰恰相反——这是仓库里配置的提交钩子在正常工作,只是它拦下了你这次提交。husky是一个把 Git hooks 管理起来的工具,pre-commit是 Git 原生支持的钩子之一,触发时机是你在终端敲下git commit之后、提交对象真正写入本地仓库之前。也就是说,代码还没进版本库,钩子先跑一遍,跑不过就不让提交。
这个机制的价值在于把检查前移。很多团队会把代码格式化、静态检查、提交信息规范校验、敏感信息扫描这些动作挂在pre-commit上,避免脏代码进入主干后再靠 CI 兜底。CI 跑一次要几分钟甚至更久,而本地钩子通常几秒钟就能给出反馈,修复成本差了一个数量级。所以看到这个报错,正确的心理预期不是“怎么绕过它”,而是“它想让我改什么”。
从报错形态上看,husky > pre-commit后面通常会跟一段具体的失败输出,比如某个 lint 工具报了多少个 error、某个脚本退出码非零、某个命令找不到。真正有用的信息在第二行往后,第一行只是 husky 在告诉你“我现在要执行 pre-commit 钩子了”。很多人截图只截第一行来问,结果谁也帮不上忙,因为失败原因完全藏在后面的日志里。
适合读这篇内容的人大致分三类:刚接手一个带 husky 配置的仓库、提交时被拦下来不知道怎么办的新同学;自己搭了钩子但脚本老是误报、想搞清楚原理的开发者;以及团队里负责工程化配置、需要决定钩子该挂什么、怎么让队友少踩坑的人。下面我会按“为什么这么设计、钩子脚本长什么样、报错怎么逐类排查、怎么改才不误伤”的顺序展开,中间穿插我自己踩过的坑。
2. 钩子的整体设计与选型逻辑:为什么是 husky 而不是手写脚本
2.1 Git 原生钩子的痛点与 husky 的补位
Git 本身支持钩子,仓库根目录下的.git/hooks里放一个名为pre-commit的可执行文件就能生效。问题在于.git目录不进版本控制,你本地配好的钩子推不到仓库里,队友克隆下来是空的。团队想统一规范,就得每个人手动复制脚本,或者写个初始化脚本让大家记得跑,实际执行率很低。
husky 解决的就是“钩子配置如何随仓库分发”这件事。它把钩子脚本放在项目目录里(新版本是.husky/目录),通过一次安装命令把 Git 的钩子入口指向 husky 的调度器,这样配置跟着代码走,克隆完执行一次安装就全员生效。这个设计的关键取舍是:牺牲一点安装步骤,换取规范的可分发性和一致性。
早期 husky 版本(v4 及以前)是在package.json里写husky.hooks字段,安装时由 husky 往.git/hooks写脚本。v5 之后改成基于.husky/目录的 shell 脚本方案,每个钩子一个文件,内容就是你要执行的命令。v7 之后进一步简化,钩子文件里直接写命令即可,不再需要 husky 的 shell 包装头。版本差异是排查问题时第一个要确认的点,因为不同版本的目录结构、安装命令、脚本写法都不一样,拿旧教程套新版本很容易配错。
2.2 pre-commit 该挂什么:检查项的选择标准
不是所有检查都适合放在pre-commit。判断标准我一般用两条:一是快,二是确定性高。快是指单次执行控制在几秒到十几秒,超过这个量级开发者会开始烦躁,烦躁就会想办法绕过,规范就形同虚设。确定性高是指同样的代码每次跑结果一致,不依赖网络、不依赖外部服务状态,否则会出现“我本地过了 CI 没过”或者“昨天能提交今天不能”的玄学问题。
按这个标准,适合挂的包括:代码格式化(如 prettier、gofmt)、静态检查的增量部分(只检查暂存文件)、提交信息格式校验(commitlint)、明显的敏感信息模式扫描、大文件拦截。不适合挂的包括:全量单元测试、需要拉依赖的构建、需要访问远端服务的集成检查,这些交给 CI 更合适。
这里有个容易被忽略的细节:pre-commit执行时,工作区可能有未暂存的改动。如果钩子里的工具直接对工作区文件做格式化,会把没打算提交的改动也一起改掉,造成混乱。稳妥的做法是只处理暂存区内容,或者用工具提供的--staged类参数,再或者用 lint-staged 这类工具做中转,它会把暂存文件挑出来单独处理再放回去。这个取舍直接决定了钩子是“帮忙”还是“添乱”。
2.3 版本与依赖的匹配关系
husky 的版本和 Node 版本、包管理器、Git 版本之间存在匹配关系。比如某些 husky 版本要求 Node 14 以上,老项目还在 Node 12 上跑就会安装失败或者钩子不触发。再比如用 pnpm 的项目,安装命令和 npm、yarn 略有差异,prepare脚本的触发时机也不同。这些匹配问题不会在报错里直接告诉你,但会表现为“钩子根本没跑”或者“跑了但命令找不到”。
我的经验是,接手一个仓库先看三处:package.json里 husky 的版本号、.husky/目录下钩子文件的内容、以及package.json的scripts.prepare字段。这三处能还原出钩子的完整链路,比盲目搜索报错信息高效得多。
3. 钩子脚本的内部结构与执行链路
3.1 从 git commit 到脚本执行的完整路径
敲下git commit之后,Git 会去.git/hooks找名为pre-commit的可执行文件。husky 安装时会把这里替换成一个调度脚本,它再去执行.husky/pre-commit这个项目内的文件。所以真正的检查逻辑在.husky/pre-commit里,内容通常是一行或几行命令,比如npx lint-staged或者npm run lint。
这条链路上任何一环断了都会表现为提交失败或钩子不触发:.git/hooks/pre-commit没有执行权限、.husky/pre-commit文件不存在、文件里的命令在当前环境找不到、命令执行返回非零退出码。排查时按这条链路从后往前捋,基本不会漏。
一个典型的.husky/pre-commit内容长这样:
npx lint-staged或者带多个检查的:
npm run lint:staged npm run check:commit-msg注意这里不要写交互式命令,也不要写需要人工确认的脚本,因为钩子执行环境是非交互的,卡住等输入会让提交一直挂着。
3.2 退出码决定一切
钩子是否放行,唯一依据是脚本的退出码。退出码为 0 放行,非 0 拦截。所以任何“检查失败但脚本最后返回了 0”的写法都会让钩子失效,反过来任何“检查通过但脚本意外返回非 0”的写法都会造成误拦。这是排查时最需要盯住的点。
常见的误拦来源包括:脚本里某条命令因为环境差异返回非零(比如某个二进制找不到)、管道命令中前一个命令失败但被后一个命令的成功掩盖或反之、脚本末尾多了一行无关命令导致退出码被覆盖。我见过一个案例,钩子脚本最后加了一行echo "done",结果前面检查失败的非零退出码被 echo 的成功覆盖,钩子永远放行,团队以为检查生效了其实一直没跑。这种问题不看退出码根本发现不了。
3.3 lint-staged 的增量处理机制
大部分项目的pre-commit最终落到 lint-staged 上。它的工作方式是:读取 Git 暂存区的文件列表,按配置里的 glob 匹配分组,对每组文件执行对应命令,命令执行完把修改后的文件重新加回暂存区。这个“重新加回”很关键,它保证了格式化结果会进入本次提交,而不是留在工作区。
配置一般写在package.json的lint-staged字段或独立的配置文件里,形如:
{ "lint-staged": { "*.{js,ts}": ["eslint --fix", "prettier --write"], "*.css": ["stylelint --fix"] } }这里有个顺序问题:数组里的命令按顺序执行,前一个失败会中断后续。所以一般把修复类命令放前面、纯检查类放后面,或者反过来根据团队习惯定。另外 glob 匹配的是暂存文件的路径,如果文件在子目录里,命令的工作目录和路径解析要确认清楚,否则会出现“文件明明暂存了但 lint-staged 说没匹配到”的情况。
4. 报错分类排查:把日志读成行动清单
4.1 命令找不到类报错
典型表现是command not found、npx: command not found、sh: xxx: not found。这类问题的根因通常是钩子执行时的环境变量和你在终端里手动执行时不一致。GUI 客户端(比如某些图形化 Git 工具)触发的提交,PATH 往往比终端里窄,nvm、volta 这类版本管理工具注入的路径可能不在其中,导致 node、npx 找不到。
解决办法有两个方向:一是让钩子脚本显式加载环境,比如在脚本开头 source 版本管理工具的初始化脚本;二是把命令写成绝对路径或通过包管理器提供的入口调用。前者更通用但依赖具体环境,后者更稳但可移植性差。团队协作场景下,我倾向于在文档里写清楚“请从终端提交”,并给 GUI 用户提供一个显式指定 node 路径的备选方案。
还有一种变体是命令存在但依赖没装,报错类似Cannot find module 'xxx'。这通常是克隆后没执行安装,或者prepare脚本没跑导致 husky 没初始化。确认node_modules存在、prepare脚本执行过,基本能解决。
4.2 检查未通过类报错
这类报错信息最丰富,也最容易被误读。比如 eslint 报了一堆 error,但其中有些是 warning 级别、有些是本次改动引入的、有些是历史遗留的。钩子拦下你,不代表所有报错都得你改,得区分责任边界。
我的处理顺序是:先看报错文件是不是本次暂存的文件,再看报错规则是不是本次改动触发的。lint-staged 只处理暂存文件,所以理论上报错都落在你改过的文件上,但如果是整个文件被重新格式化导致历史问题暴露,那就需要判断是顺手修掉还是调整规则。团队里常见的约定是“新代码必须过,老代码逐步修”,对应到配置上就是规则分级和忽略文件的管理。
格式化类报错(prettier 报格式不符)通常最好处理,跑一次--write就平了。但要注意格式化可能引入大范围 diff,如果本次提交只想改一行逻辑,格式化会把整个文件搅乱,review 成本陡增。这种时候可以只对改动行做格式化,或者把格式化单独拆一个提交。
4.3 提交信息校验类报错
如果钩子链里挂了 commitlint,报错会出现在提交信息不符合约定格式时,比如要求type(scope): subject而你写了一句大白话。这类报错信息通常很明确,告诉你哪条规则没过、期望格式是什么。处理方式要么改提交信息,要么确认团队的约定是否过严。
有个细节:pre-commit阶段其实拿不到完整的提交信息(提交信息在commit-msg钩子阶段才可用),所以提交信息校验一般挂在commit-msg而不是pre-commit。如果你在pre-commit报错里看到提交信息相关的内容,说明钩子挂错了阶段,需要检查.husky/目录下文件名和实际逻辑是否对应。
4.4 权限与文件系统类报错
Permission denied出现在钩子文件没有执行权限时。husky 安装一般会处理好权限,但在某些文件系统(比如挂载的共享目录、Windows 与类 Unix 双系统共享的仓库)上权限位可能丢失。处理方式是给.husky/pre-commit补上执行权限,并确认.git/hooks/pre-commit也是可执行的。
另一类是换行符问题。Windows 上编辑的脚本带 CRLF,在类 Unix 环境执行时报bad interpreter或类似错误。仓库里配.gitattributes统一换行符,或者编辑器里设置保存为 LF,能从源头避免。
5. 实操:从零复现一套可用的 pre-commit 配置
5.1 安装与初始化
假设是一个 Node 项目,包管理器用 npm。第一步装依赖:
npm install --save-dev husky lint-staged第二步初始化 husky。新版本用:
npx husky init这个命令会创建.husky/目录、写入一个示例pre-commit文件、并在package.json的scripts里加上prepare脚本。prepare的作用是在npm install之后自动执行 husky 安装,保证队友克隆后装完依赖钩子就生效,不需要额外记一条命令。
第三步编辑.husky/pre-commit,写入实际要执行的命令:
npx lint-staged第四步配置 lint-staged,在package.json里加字段:
{ "lint-staged": { "*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"], "*.{json,md}": ["prettier --write"] } }到这里链路就通了。可以故意改一个文件、暂存、提交,观察钩子是否执行、格式化是否生效、失败时是否拦截。
5.2 参数与命令的选择依据
eslint --fix放在前面,是因为它既能修一部分问题又能报出修不了的问题,先跑它可以让后续格式化基于修复后的代码进行。prettier --write放在后面做统一格式。如果团队用 stylelint 管样式,同理加一组。命令数组的顺序不是随意的,前一个命令的输出是后一个命令的输入,顺序错了可能白跑。
关于是否加--fix有个取舍:加了会自动改代码,提交时可能发现文件被改了但没重新 review;不加则只报错不修,开发者得手动改。我的习惯是格式化类加自动修复,逻辑检查类只报错,因为逻辑问题自动修有风险。
5.3 验证钩子真的生效
配置完不能只看“提交成功了”就认为钩子在工作,得验证拦截能力。做法是故意写一个明显违规的文件(比如未使用的变量触发 eslint error),暂存后提交,确认被拦下且报错指向该文件。再故意写一个格式混乱的文件,确认提交后文件被自动格式化。两个方向都验证过,才算钩子真正生效。
这一步很多人跳过,结果钩子因为权限、路径、退出码问题静默失效,团队以为有检查其实裸奔,等 CI 报错才发现。我现在的习惯是每次调整钩子配置后都做一次“故意失败”测试,成本很低但能避免长期隐患。
6. 常见问题速查与避坑经验
6.1 问题速查表
| 报错特征 | 可能原因 | 处理方向 |
|---|---|---|
command not found | 钩子执行环境 PATH 不含 node/npx | 从终端提交,或脚本内显式加载环境 |
Cannot find module | 依赖未安装或 prepare 未执行 | 重新安装依赖,确认 prepare 脚本 |
| eslint 报大量 error | 暂存文件含历史问题或规则过严 | 区分本次改动与历史问题,调整规则分级 |
Permission denied | 钩子文件缺执行权限 | 补执行权限,检查文件系统 |
bad interpreter | 脚本换行符为 CRLF | 统一为 LF,配 .gitattributes |
| 钩子完全不触发 | husky 未初始化或 .git/hooks 被覆盖 | 重跑初始化,检查 .git/hooks/pre-commit |
| 检查失败但仍能提交 | 脚本退出码被覆盖 | 检查脚本末尾命令,确保失败时返回非零 |
6.2 几个容易踩的坑
第一个坑是“临时绕过”变成习惯。git commit --no-verify能跳过钩子,紧急情况下可以用,但如果团队里频繁出现,说明钩子设计有问题——要么太慢,要么误报太多。正确做法是收集绕过原因,针对性优化,而不是默认大家都该忍着。
第二个坑是钩子脚本里写全量检查。有人图省事在pre-commit里跑整个项目的 lint,文件一多就要几十秒,开发者等两次就开始绕过了。增量处理是底线,lint-staged 这类工具就是为此存在的。
第三个坑是忽略钩子对暂存区的影响。钩子里的工具修改文件后,如果没重新加回暂存区,会出现“提交的内容和看到的文件不一致”。lint-staged 处理了这个问题,但自己手写脚本时容易漏掉git add这一步。
第四个坑是跨平台协作时的路径与命令差异。Windows 的路径分隔符、shell 差异、可执行文件后缀,都会让在一个人机器上正常的钩子在另一个人机器上报错。仓库里统一开发环境约定(比如都用某种 shell、统一换行符),能减少这类问题。
6.3 让钩子“好用”而不是“烦人”的几条经验
钩子的目标是减少返工,不是增加仪式感。我判断一个钩子配置是否合理的标准很简单:开发者被拦下后,能不能在三十秒内看懂要改什么、改完能不能顺利通过。如果报错信息含糊、修复路径不明,这个钩子就是在制造摩擦。
具体做法上,报错信息要指向具体文件和行号,规则要分级(error 拦截、warning 提示),自动修复能覆盖的尽量自动修,修不了的给出明确指引。另外给钩子本身留一个“逃生通道”的说明,写清楚什么情况下可以跳过、跳过之后怎么补检查,比一味强调“不许跳过”更现实。
还有一点是钩子的执行时间要可观测。可以在脚本里加时间统计,超过阈值就优化。我见过一个项目钩子跑一次要一分多钟,后来发现是某个检查扫了整个node_modules,加个忽略配置就降到几秒。这种优化投入产出比很高,但只有先测量才能发现。
7. 从单点报错到工程化习惯
husky > pre-commit这行提示背后,其实是一整套“把质量检查左移”的工程习惯。单次报错的处理只是表层,真正值得投入的是把钩子配置成团队共识的一部分:检查什么、不检查什么、失败了怎么处理、什么情况下允许跳过。这些约定写进仓库文档,比散落在聊天记录里可靠得多。
我自己的体会是,钩子配置的演进应该跟着团队痛点走。一开始只做格式化和明显的静态检查,等大家习惯了再逐步加提交信息规范、敏感信息扫描。一次性上太多检查,阻力会大到让整个机制被绕过。小步快跑、持续调整,比一步到位更可持续。
后续如果要扩展,可以考虑的方向包括:把钩子执行耗时纳入监控、给不同目录配置不同的检查规则、把钩子与 CI 的检查项做去重避免重复劳动。这些都属于配置层面的优化,不需要改代码,但能明显改善日常提交体验。