1. 这个报错到底在拦什么:从一次提交被拒说起
husky > pre-commit这行提示出现在终端里的时候,通常伴随着一段红色报错和一个被中断的git commit。很多人第一反应是“钩子坏了”或者“husky 抽风了”,然后顺手--no-verify跳过,提交是成功了,但问题被埋进了下一次。我见过太多团队在这个环节反复踩坑,最后干脆把钩子删掉,等于把代码质量的第一道闸门自己拆了。
先把话说清楚:husky > pre-commit不是错误本身,它只是 husky 在告诉你“我现在要执行 pre-commit 钩子了”。真正让你提交失败的是钩子里面跑的那条命令——可能是 ESLint 检查出未使用变量,可能是 Prettier 发现格式不一致,可能是 commitlint 嫌你的提交信息不合规,也可能是某个自定义脚本返回了非零退出码。husky 只是个调度器,它负责在git commit这个动作发生前,把.husky/pre-commit这个脚本拉起来执行。脚本里任何一步失败,git 就会中止提交。
所以排查思路的第一原则是:别盯着 husky 看,去看钩子脚本里到底跑了什么。这个认知转变很关键,因为大量新手会把时间浪费在重装 husky、删 node_modules、怀疑 git 版本上,而真正的问题往往是一行 lint 报错。
这篇内容适合三类人:刚接手一个带 husky 配置的项目、被 pre-commit 反复拦截到心态爆炸的前端或全栈开发者、以及想给团队搭一套靠谱提交规范的负责人。我会把 husky 的工作机制、pre-commit 常见失败类型、逐层排查方法、以及怎么配置一套“既严格又不烦人”的钩子体系讲透,中间穿插我自己踩过的坑和团队里真实发生过的翻车现场。读完你至少能做到:看到这行提示不再慌,三分钟内定位到根因,并且知道哪些检查该放在 pre-commit、哪些该挪到 CI 去。
2. husky 与 pre-commit 的协作机制拆解
2.1 git hooks 的原始形态与 husky 的定位
要理解 husky,得先理解 git 自己的钩子机制。git 在.git/hooks/目录下预留了一批可执行脚本,文件名是固定的,比如pre-commit、commit-msg、pre-push。当你在仓库里执行git commit时,git 会先去找.git/hooks/pre-commit,如果这个文件存在且可执行,就先跑它,退出码为 0 才继续提交,非 0 就中止。这是 git 原生能力,跟 husky 没有半点关系。
问题在于.git/hooks/这个目录不进入版本控制。你本地配了钩子,同事 clone 下来是空的,团队根本没法共享同一套检查规则。husky 解决的正是这个痛点:它把钩子脚本放到项目根目录的.husky/文件夹里,这个文件夹是提交到仓库的,然后通过husky install在.git/hooks/里建立指向.husky/的入口。这样每个人 clone 之后跑一次安装,钩子就自动生效了。
husky 的版本演进值得提一句。v4 及以前是通过package.json里的husky.hooks字段配置,安装时写进.git/hooks;v5 开始改成基于.husky/目录的 shell 脚本模式,v7 之后进一步简化,安装命令变成husky install,并且要求 Node 版本和 git 版本达到一定门槛。如果你在项目里看到"husky": {"hooks": {...}}这种配置,那基本是 v4 时代的老项目,升级时钩子写法要整体迁移,这是很多“钩子突然不生效”问题的根源。
2.2 pre-commit 钩子的执行时序
一次git commit的完整时序大致是这样的:git 先收集暂存区内容,然后触发pre-commit钩子,钩子脚本执行完毕后,如果通过,接着触发prepare-commit-msg(生成提交信息模板),再触发commit-msg(校验提交信息),最后才真正写入提交对象。也就是说 pre-commit 是整条链路上最早的一道关卡,它拿到的输入是暂存区(staged)的文件,而不是工作区全部文件。
这个细节极其重要,因为它决定了 lint 工具该怎么配。很多项目在 pre-commit 里直接跑eslint .,扫全仓库,结果你只改了一个文件,却因为三年前某个遗留文件的报错被拦下来,体验极差。正确做法是用lint-staged这类工具,只对暂存区里匹配的文件跑检查。husky 负责“什么时候跑”,lint-staged 负责“对哪些文件跑什么命令”,两者是搭档关系,缺一不可。
.husky/pre-commit脚本的典型内容长这样:
#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx lint-staged第一行指定 shell,第二行加载 husky 的运行环境(v8 之后这行在新版本里可以省略,但老项目保留也无妨),第三行才是真正的业务逻辑。你看到的husky > pre-commit提示,就是 husky 在加载环境时打印的。如果第三行命令返回非零,提交就失败。
2.3 为什么钩子会“突然”开始报错
有个高频困惑:昨天还能提交,今天什么都没改就报错了。这种情况通常有几个来源。一是依赖更新,比如lint-staged或某个 lint 插件升级后规则变严,或者 Node 版本切换导致某个包行为变化。二是配置文件被改动,比如.eslintrc里新增了规则,或者package.json的lint-staged字段被同事调整。三是暂存区里混进了不该提交的文件,比如某个生成产物被git add了,lint 一跑就炸。四是钩子脚本本身的权限或路径问题,尤其在 Windows 和 WSL 混用的团队里,换行符(CRLF/LF)不一致会让 shell 脚本直接执行失败。
我印象很深的一次,某项目组连续两天有人提交失败,报错信息是pre-commit hook failed (add --no-verify to bypass),但看不到具体 lint 输出。查了半天发现是.husky/pre-commit文件在 Windows 上被 git 自动转成了 CRLF 换行,shell 解析第一行 shebang 时带了\r,导致脚本根本没跑起来,husky 只报了个笼统的失败。解决办法是在.gitattributes里加一行*.sh text eol=lf,或者干脆在.husky/目录下加.gitattributes声明* text eol=lf。这种坑,文档里不会写,只有被咬过才知道。
3. pre-commit 报错的六大类型与逐层排查法
3.1 类型一:lint 规则不通过
这是最常见的一类。ESLint 报no-unused-vars、no-console、@typescript-eslint/no-explicit-any,Stylelint 报属性顺序,Prettier 报格式差异,都属于这一类。终端里通常能看到具体的文件路径、行号、规则名和错误描述。
排查动作很直接:把报错信息里的规则名复制出来,去对应配置文件里确认这条规则是error还是warn。如果是error,那钩子拦截是设计如此,你要么改代码,要么跟团队商量把规则降级。这里有个经验:pre-commit 阶段只应该拦截 error 级别的规则,warn 级别不应该阻断提交。很多项目把一堆风格类规则设成 error,结果每次提交都要跟格式较劲,团队怨气很大。我的建议是把纯格式问题交给 Prettier 自动修复,把逻辑类问题(未使用变量、隐式 any、危险的相等判断)留在 error 级别。
如果报错文件你根本没改过,那大概率是 lint 命令扫了全仓库而不是暂存区。检查lint-staged配置,确认命令是eslint --fix而不是eslint .。前者由 lint-staged 传入文件列表,后者无视暂存区。
3.2 类型二:lint-staged 配置与文件匹配错位
lint-staged的配置是一个 glob 到命令的映射,比如:
{ "lint-staged": { "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,scss}": ["stylelint --fix"], "*.md": ["prettier --write"] } }常见故障是 glob 写错导致命令没匹配到文件,或者匹配到了不该匹配的文件。比如*.ts会匹配到node_modules里的类型声明(如果被误 add),也会匹配到dist目录下的产物。更隐蔽的是,某些文件被.gitignore忽略但被强制 add 了,lint-staged 照样会处理它们。
还有一种情况:命令数组里前一个命令失败,后面的命令不会执行,但 lint-staged 的报错信息有时不够清晰,让人误以为是最后一个命令的问题。排查时可以把命令拆开单独跑,比如手动执行npx eslint --fix 某个文件,看真实输出。
提示:
lint-staged从 v10 开始默认使用--concurrent并行执行多个 glob 任务,如果两个任务操作同一批文件(比如 ESLint 和 Prettier 都处理.ts),可能产生竞争。稳妥做法是把它们合并到同一个 glob 的命令数组里,按顺序执行。
3.3 类型三:commit-msg 校验拦截
虽然报错提示写的是pre-commit,但有时候真正拦截你的是commit-msg钩子,尤其是用了 commitlint 的项目。现象是:代码检查全过了,提交信息一写,又被拒。commitlint 的典型规则是提交信息必须符合 Conventional Commits 格式,比如feat: 新增登录接口、fix: 修复列表分页越界。如果你写的是改了点东西或者update,就会被拦。
这类问题的排查要看完整报错,commitlint 会明确告诉你哪条规则不满足,比如subject may not be empty、type must be one of [feat, fix, docs, ...]。解决办法要么改提交信息,要么调整commitlint.config.js里的规则。团队里推 commitlint 之前一定要先对齐规范,否则新人第一天就被拦十次,体验非常劝退。
3.4 类型四:钩子脚本权限与环境问题
.husky/pre-commit在类 Unix 系统上需要有可执行权限。如果从 Windows 提交、在 Linux 或 macOS 上 clone,权限位可能丢失,表现为hint: The '.husky/pre-commit' hook was ignored because it's not set as executable。解决命令是chmod +x .husky/pre-commit,并且把这个权限变更提交上去(git 会记录文件模式)。
另一个环境问题是 Node 版本。husky v8 要求 Node 14 以上,lint-staged 新版本对 Node 也有要求。如果团队成员 Node 版本参差,钩子行为会不一致。建议在package.json里用engines字段声明版本范围,配合.nvmrc或.node-version文件统一。
3.5 类型五:依赖缺失或安装不完整
npx lint-staged在依赖没装全的环境里会报command not found或直接失败。常见于 CI 环境、新同事首次 clone、或者node_modules被部分清理过。husky 的prepare脚本("prepare": "husky install")在npm install后自动执行,但如果安装时加了--ignore-scripts,钩子就不会被注册。
排查方法:手动跑npx husky install,看是否报错;再跑npx lint-staged --debug,看它能不能找到配置和命令。--debug会打印匹配到的文件列表和执行的命令,信息量很大,是定位这类问题的利器。
3.6 类型六:暂存区状态异常
git 的暂存区在某些操作后会处于中间状态,比如git add -p部分暂存、merge 冲突未解决、rebase 进行中。这时候 pre-commit 钩子拿到的文件列表可能跟你以为的不一样,lint 结果自然对不上。还有一种情况是文件被git add后又修改了工作区内容,lint-staged 默认处理的是暂存区版本,但某些工具会读工作区文件,导致检查结果和实际提交内容不一致。
遇到“明明改了却还报旧错误”的情况,先git status看清楚暂存区和工作区的差异,必要时git stash后重新操作。这个习惯能省掉大量莫名其妙的排查时间。
4. 从零搭一套不折腾的提交检查体系
4.1 工具选型:husky + lint-staged + 按需的 commitlint
一套标准的提交检查体系由三层组成:husky 管钩子生命周期,lint-staged 管暂存区文件的增量检查,commitlint 管提交信息规范。三者职责清晰,不要混用。有人喜欢在 pre-commit 里塞单元测试,我不建议——测试跑得慢,每次提交等几十秒,团队很快就会用--no-verify绕过,钩子形同虚设。单元测试和完整构建应该放到 CI 或 pre-push 阶段。
安装步骤(以 npm 为例,pnpm/yarn 命令对应替换):
npm install --save-dev husky lint-staged npx husky install npm pkg set scripts.prepare="husky install" npx husky add .husky/pre-commit "npx lint-staged"最后一条命令会生成.husky/pre-commit文件并写入内容。注意 husky v9 之后husky add命令有变化,新版本推荐直接手动创建文件。如果你用的是较新版本,直接echo "npx lint-staged" > .husky/pre-commit然后chmod +x即可。
package.json里补上 lint-staged 配置:
{ "lint-staged": { "*.{js,jsx,ts,tsx}": ["eslint --fix --max-warnings=0", "prettier --write"], "*.{css,scss,less}": ["stylelint --fix", "prettier --write"], "*.{json,md,yml,yaml}": ["prettier --write"] } }--max-warnings=0这个参数值得单独说:ESLint 默认对 warning 不返回非零退出码,意味着有警告也能提交。加上这个参数后,warning 也会阻断提交。是否加取决于团队纪律,我的经验是新项目加,老项目慎加,否则历史遗留的几百条 warning 会让所有人提交不了。
4.2 参数与规则的分级策略
pre-commit 的检查强度需要跟团队成熟度匹配。我一般按三档来设计:
| 阶段 | 检查内容 | 阻断策略 | 适用场景 |
|---|---|---|---|
| 轻量档 | Prettier 格式化 + ESLint 自动修复 | 仅 error 阻断 | 团队刚引入钩子,历史包袱重 |
| 标准档 | 上述 + ESLint error 全量 + commitlint | error 与 warning 均阻断 | 大多数成熟项目 |
| 严格档 | 上述 + 类型检查(tsc --noEmit)+ 单测冒烟 | 全部阻断 | 核心库、对外 SDK |
类型检查放不放 pre-commit 是个争议点。tsc --noEmit在大项目上动辄十几秒到几十秒,放进去会显著拖慢提交。折中方案是放到 pre-push,或者用tsc-files这类只检查改动文件相关类型的工具。但要注意,TypeScript 的类型是跨文件的,只检查单文件可能漏掉类型错误,所以tsc-files适合快速反馈,完整类型检查还是交给 CI。
4.3 让钩子“快”起来的关键细节
钩子慢是团队绕过它的头号原因。几个提速手段:第一,lint-staged 只处理暂存文件,这已经是最基本的优化;第二,ESLint 开启缓存,加--cache参数,缓存文件放node_modules/.cache下,二次执行快很多;第三,Prettier 和 ESLint 的职责不要重叠,格式问题全交给 Prettier,ESLint 里关掉所有格式类规则(用eslint-config-prettier),避免两套工具互相打架还拖慢速度;第四,避免在钩子里跑git add .之类的全量操作。
还有一个容易被忽略的点:lint-staged默认会把修改后的文件重新git add,这是它--fix能生效的原因。但如果你的命令里有会删除文件的操作(比如某个脚本清理临时文件),可能导致暂存区状态混乱。命令设计上要保证幂等和只读优先。
4.4 团队协作中的落地经验
钩子配置进仓库只是第一步,真正的难点是让所有人接受它。我的做法是:第一周先只开 Prettier 自动格式化,让大家感受到“提交前自动整理代码”的便利,不阻断任何东西;第二周加 ESLint error 阻断,同时把历史遗留的 error 集中清理一轮;第三周加 commitlint,并且提前发一份提交信息模板到群里。渐进式推进比一次性上全套的存活率高得多。
另外,一定要在 README 或贡献指南里写清楚:钩子报错怎么读、怎么本地复现、什么情况下可以用--no-verify(比如紧急热修复,但事后必须补检查)。把--no-verify完全禁掉不现实,给它一个明确的、有约束的使用场景,反而能减少滥用。
5. 高频问题速查与独家避坑清单
5.1 常见报错对照表
| 报错关键词 | 大概率原因 | 处理动作 |
|---|---|---|
command not found: lint-staged | 依赖未安装或 PATH 问题 | 重跑npm install,确认node_modules/.bin在 PATH |
hook was ignored because it's not set as executable | 脚本权限丢失 | chmod +x .husky/pre-commit并提交权限变更 |
pre-commit hook failed (add --no-verify to bypass)无具体信息 | 脚本内部静默失败或换行符问题 | 手动执行.husky/pre-commit看真实输出,检查 CRLF |
subject may not be empty | commitlint 拦截,提交信息为空或格式错 | 按 Conventional Commits 重写提交信息 |
Parsing error: Cannot find module 'xxx' | ESLint 插件或 parser 缺失 | 补装对应依赖,检查.eslintrc的 extends |
lint-staged prevented an empty git commit | 所有改动被--fix后与暂存区一致 | 正常现象,说明改动已被格式化吸收,重新 add 即可 |
5.2 几个反直觉的坑
第一个坑:--no-verify会跳过所有钩子,包括 commit-msg。有人以为它只跳过 pre-commit,结果提交信息也没被校验,规范形同虚设。如果只想跳过某一类检查,应该在钩子脚本里做条件判断,而不是整体绕过。
第二个坑:husky 的prepare脚本在某些 CI 环境会失败。CI 里通常不需要钩子,但npm install会触发prepare,如果环境缺 git 或权限不足,安装直接中断。解决办法是在 CI 配置里设置HUSKY=0环境变量,husky 检测到后会跳过安装。这个环境变量是新版本 husky 提供的,老版本需要用--ignore-scripts或条件判断。
第三个坑:monorepo 里钩子的作用域。在 workspace 结构下,husky 装在根目录,但 lint-staged 的配置和 lint 工具可能分散在各子包。这时候要么在根目录统一配置并让 glob 覆盖子包路径,要么用lint-staged的cwd选项分目录处理。我见过子包各自装 husky 导致钩子重复执行的案例,提交一次跑三遍 lint,慢到怀疑人生。
第四个坑:自动修复后的文件没有重新暂存。eslint --fix改了文件,但如果命令链里某个环节出错,lint-staged 可能来不及把修复结果 add 回去,导致你看到“修复了但提交里还是旧代码”。提交后用git show --stat核对一下实际提交内容,是个好习惯。
5.3 我个人的几条硬经验
钩子脚本里永远不要写exit 0来强行放行,那等于自欺欺人。如果某条检查暂时不想启用,就从配置里移除,而不是在脚本里短路。
遇到诡异问题时,npx lint-staged --debug和手动执行.husky/pre-commit这两个动作能解决八成以上的困惑,比在网上搜报错原文高效得多。
给钩子加日志输出要克制。有人喜欢在 pre-commit 里echo一堆进度信息,结果真正的报错被淹没在噪音里。保持输出精简,只在失败时打印关键信息。
最后,定期审视钩子的必要性。半年前加的某条检查,可能随着工具链升级已经冗余,或者规则已经过时。每季度花十分钟过一遍.husky/和 lint-staged 配置,比积累一堆没人敢动的“祖传钩子”健康得多。我自己就清理过一条检查提交文件大小的脚本,它拦的是图片资源,但项目早就改成 CDN 引用了,这条检查除了偶尔误伤 markdown 里的 base64 图片外毫无用处。工具是为人服务的,别让配置反过来绑架了开发节奏。