husky pre-commit 报错排查指南:从机制到修复的完整流程
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 xxxGit 做的第一件事不是马上打包你的改动而是去core.hooksPath指向的目录里找pre-commit这个可执行文件。如果找到了就执行它并且把当前暂存区的状态作为上下文传进去。这个脚本的退出码决定了后续走向退出码为 0Git 继续生成 commit退出码非 0Git 直接中止提交你的改动还老老实实待在暂存区一个字节都不会丢。husky 生成的pre-commit脚本通常长这样不同版本略有差异#!/usr/bin/env sh . $(dirname -- $0)/_/husky.sh npx lint-staged或者在新版 huskyv9 之后里更简洁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 --chmodx .husky/pre-commit第二条命令是把权限变更记录进 Git 索引否则下次 clone 又丢了。第二个是换行符。Windows 默认 CRLFLinux/macOS 默认 LF钩子脚本第一行的 shebang#!/usr/bin/env sh如果带了\r在某些环境下会变成sh\r找不到解释器报错信息往往很隐晦比如: not found或者bad interpreter。根治办法是在仓库根目录放一个.gitattributes* textauto eollf .husky/* text eollf强制钩子脚本用 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 installv9 之前或npx huskyv9 之后重新初始化。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文件在内容也对但就是执行不了。最后定位到是换行符问题——文件被存成了 CRLFshebang 那行带了\r导致解释器路径解析失败。加上.gitattributes强制 LF 之后解决。这个案例的教训是跨平台团队一定要在仓库层面统一换行符和文件权限别指望每个人的本地 Git 配置都正确。.gitattributes和git update-index --chmodx这两件事应该在项目初始化时就做掉而不是等出了问题再补。7. 我个人的几条实操心得写到这里把散落在各节的经验收拢一下都是踩过坑之后形成的习惯供你参考。第一条看到 husky 报错先读完整输出再动手。九成的答案就在那几行里慌着去搜反而容易走弯路。我现在的习惯是先把终端输出复制到一个文本文件里逐行看把husky 的包装信息和真正的错误分开后者才是要处理的。第二条手动跑钩子脚本是最高效的定位手段。sh .husky/pre-commit这一条命令比在git commit和报错之间反复横跳快得多。养成这个习惯排查时间能砍掉一半。第三条别把--no-verify当默认选项。它是个逃生通道不是日常通道。用之前问自己一句我知道为什么被拦吗答不上来就别用。团队里如果有人频繁用它那是个信号——要么钩子配置有问题要么规范没对齐该坐下来聊聊了。第四条钩子的严格程度要跟着团队成熟度走。新项目、新人多的团队钩子先宽松点把格式和明显错误拦住就行别一上来就全量类型检查加单测那只会把人吓跑。等大家习惯了再逐步加码。工程规范是养出来的不是配出来的。第五条把钩子当代码一样维护。它跟着仓库走会被所有人执行所以它的健壮性、可读性、报错友好度都值得投入。一个会打印请先 npm install的钩子和一个只会甩 module not found 的钩子给团队带来的体验天差地别。最后说个我最近在用的一个小技巧在.husky/pre-commit里加一行计时提交完成后打印耗时。这样当钩子变慢时你能第一时间感知到而不是等到某天它慢到让人无法忍受才去优化。几行 shell 的事但对长期维护很有帮助。