1. 先搞清楚 husky 到底在拦你什么
git commit敲下去,终端突然吐出一行husky > pre-commit然后卡住或者直接报错退出,这个场景几乎每个接手过带 husky 配置仓库的人都遇到过。我第一次碰到的时候也懵——明明代码写完了,git status看着干干净净,怎么提交就被拦下来了?后来才明白,husky 本身不是报错的原因,它只是一个钩子管理器,真正拦你的是它挂载在pre-commit这个 Git 钩子上的那段脚本。报错信息里husky > pre-commit只是 husky 打印的分隔标识,告诉你"现在进入 pre-commit 钩子执行阶段了",后面跟着的内容才是真正的错误。
所以排查的第一步永远是:把husky > pre-commit后面那几行完整读一遍。很多人一看到这行红字就慌了,直接去搜"husky pre-commit 报错怎么解决",结果搜到的方案五花八门,有让你--no-verify的,有让你删.husky目录的,有让你重装 node_modules 的,挨个试一遍,问题没解决反而把仓库搞得更乱。实际上绝大多数情况下,错误信息已经把原因写得很清楚了,只是被淹没在一堆 npm 的警告里。
这篇文章我想把这件事彻底讲透:husky 的 pre-commit 钩子在整个提交流程里处于什么位置、它为什么会失败、失败之后怎么按图索骥地定位、以及那些"绕过"方案到底该不该用。适合两类人看——一类是刚接触前端工程化、被钩子拦得莫名其妙的新手,另一类是团队里负责维护仓库规范、需要给同事解释"为什么你提交不上去"的人。我会尽量用生活化的类比把机制讲明白,同时给出可以直接照着操作的排查步骤。
先建立一个基本认知:Git 本身提供了一套钩子机制,在commit、push等动作的前后插入自定义脚本,pre-commit就是在你敲下git commit之后、真正生成 commit 对象之前执行的那一段。husky 做的事情,是让这套原生钩子变得好管理——它把钩子脚本放在.husky/目录下,通过git config core.hooksPath .husky把 Git 的钩子路径指过去,这样你就能像管理普通文件一样管理钩子,还能跟着仓库一起提交、让团队所有人共享同一套规范。理解了这一层,你就知道:报错一定来自.husky/pre-commit这个文件里写的命令,而不是 husky 本身。
1.1 钩子执行链路:从 git commit 到报错退出
把链路拆开看会更清楚。你在终端敲git commit -m "xxx",Git 做的第一件事不是马上打包你的改动,而是去core.hooksPath指向的目录里找pre-commit这个可执行文件。如果找到了,就执行它,并且把当前暂存区的状态作为上下文传进去。这个脚本的退出码决定了后续走向:退出码为 0,Git 继续生成 commit;退出码非 0,Git 直接中止提交,你的改动还老老实实待在暂存区,一个字节都不会丢。
husky 生成的pre-commit脚本通常长这样(不同版本略有差异):
#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx lint-staged或者在新版 husky(v9 之后)里更简洁:
npx lint-staged关键就在最后那行命令。lint-staged会读取package.json或.lintstagedrc里的配置,对暂存的文件跑对应的检查工具——可能是 ESLint、Prettier、Stylelint、TypeScript 类型检查,也可能是自定义的脚本。只要其中任何一个工具返回非 0 退出码,整条链路就断了,Git 提交失败,终端上你看到的就是husky > pre-commit加上那个工具吐出来的错误。
所以"husky 报错"这个说法其实不准确,准确的说法是"pre-commit 钩子里配置的某个检查没通过"。这个认知差非常关键,因为它直接决定了你该往哪个方向排查:不是去修 husky,而是去修那个没通过的检查,或者修你的代码。
1.2 为什么团队非要在提交前拦一道
有人会问,检查放到 CI 里跑不行吗,为什么非要在本地提交时拦?这个问题我在团队里被问过不止一次。答案在于反馈成本。CI 跑一轮少则几分钟多则半小时,等你收到失败通知,上下文早就切走了,回头再看是哪一行 ESLint 报错,心态已经崩了。pre-commit 把检查前置到提交那一刻,你刚写完代码、脑子里还是热的,报错信息直接怼到脸上,改起来最快。这是"左移"思想在工程实践里最朴素的落地。
另一个原因是保护提交历史。一个仓库的 commit 历史是要被人读的,如果每隔几个 commit 就混进去一个格式混乱、有语法隐患的版本,后面排查问题用git bisect的时候会非常痛苦。pre-commit 相当于在入口处设了个质检员,不合格的改动根本进不了历史。代价是提交变慢了几秒到几十秒,这个 trade-off 在绝大多数团队里是划算的。
但这也埋下了矛盾的种子:质检员太严格,开发者嫌烦;太宽松,又起不到作用。后面我会专门讲这个平衡怎么把握,以及为什么"绕过钩子"这个选项要慎用。
2. 报错信息逐类拆解:你的钩子到底卡在哪一步
既然报错来自钩子里的具体命令,那排查思路就很明确了:看错误信息属于哪一类。我把实际工作中遇到的情况归了几类,覆盖了九成以上的场景。你可以对着自己的终端输出找对应条目。
2.1 检查工具报错:ESLint、Prettier、类型检查没通过
这是最常见的一类。终端上会明确告诉你哪个文件、哪一行、什么规则报错,比如:
husky > pre-commit (node v18.17.0) ✔ Preparing lint-staged ✔ Running tasks for staged files ❯ src/components/Button.tsx ✖ eslint --fix [FAILED]看到✖ eslint --fix [FAILED],问题就很清楚了:Button.tsx里有 ESLint 规则没通过,而且--fix自动修复也没能搞定(通常是逻辑类规则,比如未使用的变量、no-console、react-hooks/exhaustive-deps这类自动修不了的)。这时候正确的动作是单独把这个文件跑一遍检查,看完整报错:
npx eslint src/components/Button.tsx不要依赖 lint-staged 的输出,因为它为了简洁会截断信息。单独跑能看到完整的规则名和行号,改起来有的放矢。Prettier 的问题类似,通常是格式不一致,npx prettier --check <file>能定位,--write能直接修。类型检查(tsc --noEmit)报错则会给出类型不匹配的具体位置,这类问题自动修复帮不上忙,只能手动改。
这里有个经验:lint-staged 只检查暂存的文件,所以有时候你本地全量跑npm run lint是过的,但提交时偏偏报错。原因可能是你改了 A 文件,但 A 文件依赖的 B 文件里有类型问题,而 B 没被暂存。这种情况在 TypeScript 项目里尤其常见,因为类型检查是跨文件的。遇到"本地全量过、提交报错"的诡异现象,先想想是不是这个原因。
2.2 环境类报错:命令找不到、node 版本不对
第二类报错长得不太一样,它不指向具体代码,而是说命令本身跑不起来:
.husky/pre-commit: line 4: npx: command not found或者:
Error: Cannot find module 'lint-staged'这类问题的根因是钩子执行时的环境和你终端里的环境不一致。Git 钩子是由 Git 进程调起的子进程,它继承的环境变量、PATH 可能和你交互式 shell 里的不一样。最典型的场景是用 nvm 管理 node 版本的人:你在终端里node -v是 v18,但 Git 钩子执行时可能拿到的是系统自带的旧版 node,或者干脆找不到 node。因为 nvm 是通过修改 shell 的 PATH 来切换版本的,而 Git 调钩子时未必走了你那套 shell 初始化逻辑。
解决办法有两个方向。一是确保钩子脚本里用的命令是绝对可靠的,比如把npx lint-staged换成npm exec lint-staged或者直接写./node_modules/.bin/lint-staged,绕开 PATH 查找。二是在钩子脚本开头显式加载版本管理器,比如:
#!/usr/bin/env sh . "$HOME/.nvm/nvm.sh" nvm use npx lint-staged不过第二种做法有可移植性问题,团队里用不同版本管理器的人(nvm、fnm、volta)配置不一样,写死某一种会让别人跑不起来。所以我更推荐第一种:用相对路径直接调本地安装的二进制,./node_modules/.bin/下面的东西是跟着仓库走的,不依赖全局环境,最稳。
Cannot find module 'lint-staged'则是依赖没装或者装坏了。先确认node_modules/.bin/lint-staged存在,不存在就npm install;存在还报这个错,多半是 node_modules 和 lock 文件不同步,删掉重装最省事。
2.3 权限与换行符:Windows 上特有的两个坑
如果你在 Windows 上开发,还有两个高频坑。第一个是权限:husky 的钩子脚本需要有可执行权限,从 Windows 提交上去的脚本可能在 Linux/macOS 上丢失执行位,导致Permission denied。修复很简单:
chmod +x .husky/pre-commit git update-index --chmod=+x .husky/pre-commit第二条命令是把权限变更记录进 Git 索引,否则下次 clone 又丢了。
第二个是换行符。Windows 默认 CRLF,Linux/macOS 默认 LF,钩子脚本第一行的 shebang#!/usr/bin/env sh如果带了\r,在某些环境下会变成sh\r找不到解释器,报错信息往往很隐晦,比如: not found或者bad interpreter。根治办法是在仓库根目录放一个.gitattributes:
* text=auto eol=lf .husky/* text eol=lf强制钩子脚本用 LF。这个文件值得每个跨平台协作的仓库都加上,能省掉大量莫名其妙的"在我机器上是好的"。
2.4 配置缺失:lint-staged 找不到任务定义
还有一类报错是配置层面的:
⬢ lint-staged: config not found或者 lint-staged 跑完了但什么都没做,提示No staged files match any configured task。前者说明package.json里没有lint-staged字段,也没有.lintstagedrc系列配置文件;后者说明配置里的 glob 匹配不到你暂存的文件类型。比如你只配了"*.{js,ts}",结果这次提交的是.vue文件,自然什么都不跑——这种情况不算报错,但如果你期望它检查却没检查,就是配置漏洞。
排查配置问题,先看package.json:
{ "lint-staged": { "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,scss}": ["stylelint --fix"], "*.md": ["prettier --write"] } }确认字段存在、glob 覆盖了你改的文件类型、命令在你本地能单独跑通。三者缺一,钩子就会以各种方式失败或空转。
3. 一套可复现的排查流程:从报错到定位到修复
光知道分类还不够,实际排查时需要一条清晰的路径,避免东一榔头西一棒子。我把自己常用的流程整理成下面这几步,基本能覆盖从"完全不知道哪错了"到"精确定位并修复"的全过程。
3.1 第一步:把钩子的真实输出完整捞出来
默认情况下,lint-staged 的输出是经过美化的,会折叠、截断。想看全量信息,加--verbose或者临时把钩子里的命令改成直接跑检查工具。更彻底的办法是手动执行钩子脚本,模拟 Git 的调用:
sh .husky/pre-commit这样跑出来的输出和 Git 调起时几乎一致,但你能看到完整的 stdout 和 stderr,不会被 Git 的包装层干扰。如果这一步就复现了报错,说明问题确实在钩子脚本本身;如果手动跑通过了但git commit还是失败,那问题多半在环境差异上(回到 2.2 节)。
我习惯在这一步顺手确认一下 husky 是否真的接管了钩子:
git config core.hooksPath正常应该输出.husky(或者.husky/_,取决于版本)。如果输出为空或者指向别处,说明 husky 没装好或者被别的工具覆盖了,这时候报错可能根本不是 husky 引起的,得先npx husky install(v9 之前)或npx husky(v9 之后)重新初始化。
3.2 第二步:把 lint-staged 的任务拆开单独跑
定位到是 lint-staged 的问题后,别急着改代码,先把它的任务拆开。lint-staged 本质上是对每个匹配的文件跑一串命令,你可以手动模拟这个过程:
# 看当前暂存了哪些文件 git diff --cached --name-only # 对其中某个文件单独跑检查 npx eslint <file> --fix npx prettier --check <file>哪个命令报错,问题就锁定在哪个工具上。这一步的价值在于把"钩子报错"这个模糊概念还原成"某个工具对某个文件报了某个错",后者是可操作的,前者只会让人焦虑。
有个细节要注意:lint-staged 默认会 stash 掉未暂存的改动再跑检查,跑完再恢复。这个机制在某些情况下会出问题,比如你的项目里有生成文件、或者 stash/pop 过程中有冲突,就会看到一些和代码本身无关的报错。遇到这种诡异情况,可以加--no-stash参数关掉这个行为再试:
npx lint-staged --no-stash如果关掉 stash 后问题消失,那根因就是 stash 机制和你的项目结构冲突,需要在配置里调整或者排查是哪些文件导致的。
3.3 第三步:区分"代码问题"和"环境问题"
排查到具体命令后,要判断这个失败是代码本身不合格,还是环境没配好。判断方法很简单:在干净的终端里,用和钩子相同的命令、相同的文件,手动跑一遍。跑通了,说明是环境差异;跑不通且报的是规则错误,说明是代码问题。
环境问题的典型特征是:报错信息里出现"找不到命令""模块不存在""版本不匹配""权限拒绝"这类和代码内容无关的词。代码问题的特征是:报错精确指向文件的行列,附带规则名或类型错误描述。这两类的修复方向完全不同,混为一谈只会浪费时间。
我见过最冤的一种情况是:开发者本地 node 版本是 16,项目要求 18,钩子里跑的 ESLint 因为语法不支持直接崩了,报错看起来像是代码问题,实际上是版本问题。所以排查时顺手node -v确认一下版本,能省不少事。
3.4 第四步:修复后如何验证钩子真的通了
改完代码或者环境后,别急着庆祝,要验证钩子确实能通过。最直接的办法是重新git commit,但更高效的是先手动跑一遍钩子:
sh .husky/pre-commit && echo "钩子通过"输出"钩子通过"就说明这条链路没问题了。如果还想更保险,可以故意制造一个会被拦下的改动(比如加一行console.log),确认钩子确实能拦住,再改回去——这能验证钩子不是"假通过"(比如配置写错导致什么都没检查)。
验证通过后再正式提交。这里提醒一句:不要用git commit --no-verify来"验证",那个参数是跳过钩子的,用它提交成功不代表钩子修好了,只代表你绕过了它。这个参数后面会专门讲。
4. 那些"绕过"方案,到底该不该用
搜 husky 报错,十条答案里有八条会让你加--no-verify。这个参数确实能让你立刻提交成功,但它是一把双刃剑,用错了地方后患无穷。我想把这件事讲清楚,因为很多团队对它的态度是模糊的,导致有人滥用、有人完全不敢用。
4.1 --no-verify 跳过的到底是什么
git commit --no-verify(简写-n)的作用是告诉 Git:这次提交跳过所有 pre-commit 和 commit-msg 钩子。注意,它跳过的是钩子,不是 Git 的其他检查。你的改动照样进暂存区、照样生成 commit,只是质检员被临时请走了。
从机制上说,它没有任何"后门"性质,就是 Git 官方提供的一个开关,设计初衷是给那些明确知道自己在做什么、且钩子检查不适用当前场景的人用的。比如你在做一次紧急的热修复,改的是一行配置,但钩子因为某个历史遗留的 lint 规则卡住,这时候跳过是合理的。问题在于,很多人把它当成了"报错就加 -n"的条件反射,这就把质检员彻底架空了。
我的态度是:--no-verify可以用,但必须满足两个条件——你知道为什么钩子会拦你,并且你确认这次拦截对你的改动不适用。如果只是"我不知道哪错了,先提交上去再说",那就是在给自己和团队埋雷。因为被跳过的检查不会消失,它会在 CI 里以更晚、更贵的方式回来找你,或者更糟——根本没回来,带着问题进了主干。
4.2 什么情况下跳过是合理的
说几个我认为合理的场景。第一,钩子本身坏了但你要紧急提交。比如某个依赖升级导致 lint-staged 跑不起来,而你手上有个必须马上合入的修复,这时候先--no-verify提交,回头再修钩子,是务实的做法。但前提是你得记着回头修,不能提交完就忘了。
第二,改动类型和钩子检查不匹配。比如你只改了一个.md文档,但钩子配置里有个全量类型检查,跑起来要两分钟还报了个和你改动无关的历史错误。这种时候跳过是合理的,因为钩子的设计没有区分改动范围。
第三,WIP(进行中)提交。有些人习惯频繁提交本地快照,这些 commit 最终会被 squash 掉,不进入主干历史。对这类提交跳过检查无可厚非,但要确保它们不会被直接推到共享分支。
反过来,不合理的场景也很明确:代码确实有 lint 错误、类型错误、测试失败,你只是不想改。这种情况加--no-verify等于把问题转嫁给下一个读这段代码的人,是典型的负外部性行为。
4.3 比绕过更好的做法:让钩子更聪明
与其纠结要不要绕过,不如从根上减少"钩子误伤"的概率。几个实践下来有效的调整:
一是缩小检查范围。lint-staged 默认只查暂存文件,这已经比全量好很多,但还可以进一步——比如类型检查用tsc --noEmit全量跑很慢,可以换成只检查改动文件相关的项目,或者把类型检查挪到 CI,本地只跑 lint 和 format。检查越精准,误伤越少,大家越愿意配合。
二是区分必须和可选。把"绝对不能进主干"的检查(语法错误、明显的逻辑 bug)设为阻断,把"风格偏好"类的检查设为警告不阻断。lint-staged 支持给命令加|| true来忽略退出码,但我不推荐滥用,更好的做法是用工具本身的 warning 级别配置。
三是给钩子加超时和友好提示。钩子跑太久会让人烦躁,跑失败只给一堆英文报错也让人头大。可以在钩子脚本里加个 echo,告诉开发者"正在检查暂存文件,预计 10 秒",失败时提示"请运行 npm run lint:fix 自动修复大部分问题"。这些细节能显著降低团队对钩子的抵触。
5. 从根上减少这类报错:husky 配置的几条经验
排查是治标,配置是治本。这一节聊聊怎么把 husky 配得更稳,让"提交报错"从常态变成偶发。
5.1 版本差异:v8 和 v9 的初始化方式变了
husky 在 v9 做了一次不小的调整,很多人升级后报错就是因为还按老方式配置。v8 及之前,安装后要手动跑npx husky install,并且在package.json里配prepare脚本;钩子文件开头要有一行. "$(dirname -- "$0")/_/husky.sh"。v9 之后,husky install改成了husky,钩子脚本不再需要那行 source,直接写命令即可,而且推荐用npx husky init来初始化。
如果你接手一个老仓库,看到.husky/pre-commit里有那行 source 但报错说找不到_/husky.sh,多半是版本不匹配——要么升级了 husky 但钩子文件没更新,要么反过来。最省事的修复是删掉.husky目录重新npx husky init,然后把原来的检查命令补回去。别在原文件上缝缝补补,版本错配的坑越补越深。
5.2 钩子脚本要写得"抗环境"
前面提过用./node_modules/.bin/直接调二进制,这里再补充几个让脚本更健壮的写法。第一,脚本开头加set -e,让任何一条命令失败就立即退出,避免错误被吞掉。第二,关键命令前加个存在性检查,给出人话提示:
#!/usr/bin/env sh set -e if [ ! -x "./node_modules/.bin/lint-staged" ]; then echo "依赖未安装,请先运行 npm install" exit 1 fi ./node_modules/.bin/lint-staged这样即使依赖没装,开发者看到的也是"请先运行 npm install"而不是一堆 module not found,排查成本直线下降。第三,如果团队里 node 版本管理工具五花八门,别在钩子里写死某一种,改用.nvmrc或package.json的engines字段声明版本要求,让工具自己去对齐。
5.3 让 lint-staged 配置覆盖到位
配置漏洞是"钩子没起作用"的隐形杀手。几个容易漏的点:新增文件类型没加进 glob(比如项目引入了.vue但配置里只有.js)、子目录里的文件没被匹配到(glob 默认从项目根匹配,深层目录要用**)、以及package.json和独立配置文件同时存在导致优先级混乱。建议统一用一种配置方式,别混着来。
另外,lint-staged 的命令顺序有讲究。一般把自动修复类命令(--fix、--write)放前面,把只读检查类命令(--check、tsc)放后面,因为修复后的代码才是最终要检查的代码。顺序反了,可能出现"检查报错但修复后其实没问题"的假阳性。
6. 几个真实场景下的排查记录
理论讲完,分享几个我在实际项目里遇到的案例,都是那种"看报错完全猜不到原因"的类型,希望能给你排查时提供点灵感。
6.1 案例:本地全量 lint 通过,提交却报错
某次在一个 TypeScript 项目里,我改了一个工具函数文件,npm run lint全绿,git commit却被 ESLint 拦下,报的是一个我根本没碰的文件里的no-unused-vars。当时很困惑,后来想明白了:lint-staged 只对暂存文件跑检查,但 ESLint 在检查单个文件时会加载整个项目的配置和类型信息,如果那个"没碰的文件"恰好被我的改动间接影响(比如我改了它 import 的某个类型),ESLint 就会把它也纳入分析范围并报错。
解决办法不是去改那个文件(它可能本来就有问题,只是之前没被触发),而是先确认这个报错是否真实。手动npx eslint <报错文件>跑一遍,如果确实报错,说明是历史遗留问题被我的改动暴露了,顺手修掉;如果手动跑不报错,那就是 lint-staged 的增量检查机制导致的误报,可以调整配置或者用--no-stash再试。
6.2 案例:钩子跑得特别慢,最后超时失败
有个仓库的 pre-commit 要跑将近一分钟,某天开始频繁超时失败。排查发现是 lint-staged 配置里有一条全量tsc --noEmit,项目大了之后类型检查越来越慢,加上钩子默认没有超时保护,跑着跑着就被系统或者 CI 环境掐断了。修复思路是把类型检查从 pre-commit 挪到 pre-push 或者 CI,本地提交只保留快速的 lint 和 format。这个调整之后,提交体验好了很多,也没人再抱怨钩子烦。
这件事给我的启发是:钩子的检查项要按"反馈速度"分层。秒级的检查放 pre-commit,分钟级的放 pre-push,更重的放 CI。把所有检查都堆在提交那一刻,短期看是严格,长期看是逼着大家去用--no-verify。
6.3 案例:Windows 同事的提交永远失败
团队里一位用 Windows 的同事,每次提交都报pre-commit: not found,其他人正常。远程连过去看,.husky/pre-commit文件在,内容也对,但就是执行不了。最后定位到是换行符问题——文件被存成了 CRLF,shebang 那行带了\r,导致解释器路径解析失败。加上.gitattributes强制 LF 之后解决。
这个案例的教训是:跨平台团队一定要在仓库层面统一换行符和文件权限,别指望每个人的本地 Git 配置都正确。.gitattributes和git update-index --chmod=+x这两件事,应该在项目初始化时就做掉,而不是等出了问题再补。
7. 我个人的几条实操心得
写到这里,把散落在各节的经验收拢一下,都是踩过坑之后形成的习惯,供你参考。
第一条,看到 husky 报错,先读完整输出再动手。九成的答案就在那几行里,慌着去搜反而容易走弯路。我现在的习惯是先把终端输出复制到一个文本文件里,逐行看,把"husky 的包装信息"和"真正的错误"分开,后者才是要处理的。
第二条,手动跑钩子脚本是最高效的定位手段。sh .husky/pre-commit这一条命令,比在git commit和报错之间反复横跳快得多。养成这个习惯,排查时间能砍掉一半。
第三条,别把--no-verify当默认选项。它是个逃生通道,不是日常通道。用之前问自己一句"我知道为什么被拦吗",答不上来就别用。团队里如果有人频繁用它,那是个信号——要么钩子配置有问题,要么规范没对齐,该坐下来聊聊了。
第四条,钩子的严格程度要跟着团队成熟度走。新项目、新人多的团队,钩子先宽松点,把格式和明显错误拦住就行,别一上来就全量类型检查加单测,那只会把人吓跑。等大家习惯了,再逐步加码。工程规范是养出来的,不是配出来的。
第五条,把钩子当代码一样维护。它跟着仓库走,会被所有人执行,所以它的健壮性、可读性、报错友好度都值得投入。一个会打印"请先 npm install"的钩子,和一个只会甩 module not found 的钩子,给团队带来的体验天差地别。
最后说个我最近在用的一个小技巧:在.husky/pre-commit里加一行计时,提交完成后打印耗时。这样当钩子变慢时,你能第一时间感知到,而不是等到某天它慢到让人无法忍受才去优化。几行 shell 的事,但对长期维护很有帮助。