1. 先搞懂错误链路:husky、Git hooks 与退出码
1.1 pre-commit hook 到底什么时候触发
用过 Husky 的人都清楚,它本质上是帮你把 Git 的钩子脚本管理起来的一个工具。Git 本身自带一套事件机制,比如 pre-commit、pre-push、commit-msg 等等,每次触发 git commit 的时候,Git 会先去 .git/hooks/ 目录下找对应的可执行脚本,如果有就执行,执行完再看退出码决定是否继续。
Husky 做的事情其实并不玄乎:它在安装阶段把自身注册到 .git/hooks/ 的各个钩子脚本里,然后在用户侧的 package.json 或 .husky/ 目录中读取你自定义的命令,按钩子类型去执行。pre-commit hook exited with code 1 这个报错,字面意思就是:提交前的钩子已经跑了,但脚本最终返回了退出码 1,也就是失败,Git 因此拒绝本次提交。
一个常见的误解是:只要在终端里把 lint 跑过了,pre-commit 就不会出问题。实际上 pre-commit 钩子里跑的是一套全新的、独立于你手动终端的命令序列,它可能包含 ESLint、Prettier、TypeScript 类型检查、单元测试甚至自定义脚本。任何一个环节返回非 0,Git 都会把这笔账算到 pre-commit hook 头上,最终显示就是这句令人头皮发麻的报错。
1.2 退出码 1 到底在说什么
在 Linux / Unix 的进程模型里,退出码 0 表示成功,非 0 都表示异常。Git 对钩子脚本的要求也一样:钩子执行完如果返回值不是 0,就中止当前操作。exited with code 1 表示钩子的最后一条命令返回了 1,但注意,这并不等于钩子的“第一行命令”失败了。
实际操作中,你会看到这样的情况:husky 的输出里可能有几段 ESLint 的输出、一段 Prettier 的输出,甚至还有一段 git diff 的结果,最后才来一句 error Command failed with exit code 1。如果你着急去看最后一行,可能根本不知道真正挂掉的是哪一个命令。我习惯的做法是先看完整输出,尤其注意每个工具自己打印的 error 字样,那才是真正的失败源。
还有一点值得说:退出码 1 是绝大多数命令行工具在“有错误发生”时返回的通用值,ESLint、Prettier、Jest 都会这样。所以你不能只凭退出码判断问题性质,必须结合具体的 stdout/stderr 内容。这个习惯在调试任何 CI/CD 流程时都一样,看日志永远先看“谁报的错”,而不是只看“整体退出码”。
1.3 为什么“完备的 lint 设置”仍然会失败
我自己被这个东西坑过很多次,总结下来,pre-commit hook exited with code 1 高频触发场景大致有这几类:
- 代码里有真实的 lint 错误,比如未使用的变量、类型不匹配、尾逗号缺失。
- 格式化和 lint 规则冲突,Prettier 改了文件,ESLint 又不认,两个工具来回打架。
- husky 配置的脚本写错了,比如命令不存在、路径不对、可执行权限缺失。
- lint-staged 过滤范围不对,它尝试去处理了不该处理的文件,比如二进制资源或生成文件的 diff。
- TypeScript 的
tsc --noEmit检查覆盖了整个项目,而不仅仅是暂存区文件,导致“别人的报错”挡在了你的提交前面。 - 缓存和环境问题,比如 node_modules 损坏、husky 版本升级后钩子没重新注册。
这些原因里的前两个是良心报错,说明你的代码规范检查正在起作用,改代码就行。后面几个才是真正让人想拍桌子的,因为报错信息绕来绕去,最后可能指向一个根本不是“代码质量”的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 诊断优先:正确读取日志的姿势
2.1 完整输出从哪里来
遇到这个报错,第一步不是去网上搜“怎么跳过”,而是先把完整日志稳住。运行提交命令时,控制台会打印 husky 的执行过程,以及每个子命令的输出。如果你用的是 IDE 内置终端或 GUI 客户端,输出可能被折叠,此时建议回到命令行提交一次。
我的标准操作是:
bash复制git add .
git commit -m "chore: test commit"
然后在终端里往上翻,找第一处出现 error 或者 ✖(如果配置里有用到 lint-staged)的位置。很多命令行工具会用颜色标出错误行,但在 CI 或某些终端主题下颜色不生效,所以要养成看裸文本的习惯。
如果是 lint-staged 挂了,你通常会看到类似这样的内容:
bash复制✖ eslint --fix:
...
error: 'foo' is assigned a value but never used @typescript-eslint/no-unused-vars
这说明问题出在一个具体文件的具体规则上,修复方向非常明确。
2.2 一些快速但安全的验证命令
在修改任何东西之前,我建议先做三个验证,确认问题到底出在哪个环节:
- 手动执行钩子里的命令,看独立环境是否复现。
- 用
git diff --cached --name-only查看本次暂存了哪些文件,确认 lint-staged 会扫到谁。 - 检查 husky 是否真的注册成功,尤其是升级 husky 大版本之后。
举例来说,如果 pre-commit 配置是:
bash复制npx lint-staged
那我可以直接手动跑:
bash复制npx lint-staged
它会按配置对暂存区文件执行命令。如果手动跑也报错,说明是真实的代码或配置问题;如果手动跑能通过,那问题就出在钩子触发的环境差异上,最常见的是 PATH 环境变量或 Node 版本不一致。
还有一个非常容易被忽略的验证点:.husky/pre-commit 文件本身是否有执行权限。在 Linux/macOS 下权限丢失会导致钩子无法运行,但这通常表现为“没反应”而不是报错,后面我会单独讲。
2.3 定位问题的关键一行
我见过很多同事在这个报错出来后的第一反应是去看 husky 源码,其实真没必要。Husky 只是个执行器,问题的根源要么在“你要执行的命令”上,要么在“命令运行的环境”上。
一个高效定位法:把钩子里的命令拆开,一条一条在终端跑。比如配置是这样的:
json复制{
"husky": {
"hooks": {
"pre-commit": "lint-staged && npm run type-check"
}
}
}
那就先跑 npx lint-staged,再跑 npm run type-check,看哪个命令退出码不是 0。这一步基本能锁定 90% 的问题。剩下 10% 是“分开跑都能过,合起来挂”,这种情况多半是命令之间的状态污染,比如 lint-staged 改了文件但没重新加入暂存区,导致后续检查的还是旧内容。
3. 修复 pre-commit:分情况处理不同失败
3.1 修复 lint / test 错误而非绕过
当报错来自于 ESLint 或 Prettier 时,正确做法是修复代码。虽然这听起来像废话,但很多人第一反应是 --no-verify 跳过,这正是长期维护里最伤人的习惯——你把规则架起来了,然后又自己开后门,团队协作时别人会跟着学,规范很快就形同虚设。
对于可自动修复的问题,建议先运行:
bash复制npx eslint --fix
或
bash复制npx prettier --write
之后再 git add 并重新提交。需要注意的是,lint-staged 在默认配置下会在暂存文件上执行带有 --fix 的命令,如果修复产生了新的改动,它会自动把改动加回暂存区。但如果你不是用 lint-staged,而是直接在钩子里写死 eslint --fix,那你需要自己手动重新 git add。
3.2 临时绕过:停用 hook 的唯一合理场景
git commit --no-verify 这个参数可以跳过 git hooks,包括 pre-commit 和 commit-msg。它存在的意义不只是“作弊”,在一些特殊场景下是必要手段,比如:
- 紧急修补线上配置,改动涉及一个已知无法通过 lint 的遗留文件,当下没时间优化规则。
- 还原或 cherry-pick 操作需要保留历史状态,不希望被当前分支的钩子规则拦截。
- 脚本或自动化工具提交自动生成的文件,这些文件不经过人工代码评审。
我个人的态度是:--no-verify 可以用,但它应该是“临时止血”,留下 TODO,随后马上补一个包含规范修复的提交。如果某个分支长期使用 --no-verify,那这个分支的代码质量基本没法看。
同时我建议,如果团队有很多“不得不跳过”的情况,优先去调整规则配置,而不是滥用跳过。比如某些自动生成的目录可以加入 .eslintignore 或 .prettierignore,把冲突去掉。
3.3 修复损坏的 husky 安装
如果你确认钩子里的命令本身没问题,手动跑也能过,但还是报错,那就要考虑 husky 安装本身出问题了。这个问题在 husky v5 之后尤其常见,因为它把配置从 package.json 的 "husky": { "hooks": {} } 迁移到了项目根目录的 .husky/ 文件夹。
最典型的损坏场景:升级 husky 后没有重新执行 prepare 脚本,导致 .git/hooks/ 里的 husky 入口没有更新。解决办法是重新安装并初始化:
bash复制npm install
npx husky install
如果你使用的是 husky v4,且配置在 package.json 里,那么升级到 v5 后需要手动迁移配置,注意 .husky/ 目录不存在时钩子会静默失败。
还有一个不为很多新同事所知的问题:husky v5+ 是依赖 core.hooksPath 来工作的。你可以用下面命令检查:
bash复制git config core.hooksPath
如果输出是 .husky,说明 husky 正确接管了 Git 钩子目录;如果输出为空,或者指向 .git/hooks,那说明 husky 没有正确安装或初始化。此时执行 npx husky install 即可。
4. 搭建可用的 pre-commit 流水线(实战)
4.1 完整配置示例
说了半天修复,直接给出一套我平时在项目里验证过稳定的配置,这也是我调试该类问题时的参考基线。
首先,如果你用的是 husky v9(当前主流版本),项目根目录结构长这样:
text复制.husky/
pre-commit
commit-msg
package.json
.husky/pre-commit 文件内容:
bash复制#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
注意第一行用 #!/usr/bin/env sh 保证跨平台解释器,第二行是 husky 自动生成的环境加载脚本。接着在 package.json 里配置 lint-staged:
json复制{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css,scss}": [
"prettier --write"
]
}
}
这套配置的作用是:当你提交 app.ts 时,lint-staged 只对这一个文件依次执行 ESLint 和 Prettier,如果发现问题会尝试自动修复,修复后文件会重新加回暂存区,避免漏提交修复结果。
4.2 lint-staged 范围与文件筛选
lint-staged 的设计初衷是“只检查本次要提交的文件”,这对大型项目来说非常友好。它读取 package.json 里 lint-staged 字段的规则,用 minimatch 模式匹配文件名。
常见的坑之一是模式覆盖不全。比如你只配置了:
json复制{
"*.ts": "eslint --fix"
}
但项目里还有 .tsx 文件,提交的时候 .tsx 文件就不会被 lint。这倒不算报错,只是漏检。更麻烦的是你配置了 "*.*": "eslint --fix",把 JSON、Markdown 都塞给了 ESLint,然后 ESLint 报错 “File ignored because of a matching ignore pattern”——这种报错很容易让人误判为代码问题,其实只是配置范围太宽了。
我的建议是精确区分文件类型,把 ESLint 和 Prettier 的职责分开。ESLint 管 JS/TS 的代码质量,Prettier 统一格式化,不要让 ESLint 去处理二进制或数据文件。
4.3 手动测试 hook 的方法
配置完钩子后,最好先手动验证一遍,而不是直接改代码提交。我常用的验证命令是:
bash复制npx lint-staged
但要注意,它默认只处理暂存区文件,所以先用 git add 添加一些测试文件。更完整的验证方式是直接提交一个空提交:
bash复制git commit --allow-empty -m "test hooks"
这个命令也会触发 pre-commit hook,且不会因为没有任何文件变更而跳过。如果钩子配置有问题,这里会直接暴露。
还有一个更大工程的做法:在 CI 里添加一道专门的 hook 测试任务,用 git commit --allow-empty 跑一次,确保安装环境的钩子正常。我见过不少团队把现有代码全清掉重新克隆后,第一笔提交就报错,就是因为钩子在仓库模板里是坏的,但开发本地一直没触发过重新安装。
5. 常见报错与排查技巧实录
5.1 为什么钩子没运行
虽然标题是“pre-commit hook exited with code 1”,但实际工作中更让人头疼的是“钩子为什么没跑”。如果 hook 没有输出、提交顺畅通过,但团队其他成员都正常,那大概率是本地 husky 初始化失效了。
排查顺序:
bash复制git config core.hooksPath
ls -l .husky/pre-commit
如果 core.hooksPath 返回的不是 .husky,执行:
bash复制npx husky install
如果 .husky/pre-commit 文件存在但没有执行权限,执行:
bash复制chmod +x .husky/pre-commit
还有一个我踩过的坑:当你把项目放到 Docker 容器或某些共享文件系统时,挂载目录的文件权限可能会被重置,导致 hook 无法运行。这种情况下即使权限命令加了,下次启动容器又丢了,需要在容器的 entrypoint 里做初始化。
5.2 Windows 环境下的特殊问题
Windows 上 exited with code 1 的报错,很多时候不是代码问题,而是脚本解释器问题。husky 生成的 shell 脚本默认用 sh 执行,Windows 需要 Git for Windows 自带的 bash 环境。如果系统把 .sh 文件关联到了其他程序,或者 PATH 里缺少 Git 的 usr/bin 目录,就可能出现奇怪的失败。
解决思路是确保 Git for Windows 安装完整,并确认在终端里能执行:
bash复制sh --version
另外,Windows 下 npx 执行某些命令时,权限模型和 Unix 不同,如果 lint 脚本本身依赖路径拼接或环境变量,容易出问题。我建议项目里用 cross-env 统一环境变量,或用 lint-staged 而不是自己拼 shell 命令。
5.3 依赖环境与缓存问题
最后这类问题比较隐蔽。比如 ESLint 升级后规则名变了,旧项目里还在用废弃规则,报错信息可能只是一句 “Definition for rule 'xxx' was not found”。此时即时代码没问题,pre-commit 也会挂。
类似的还有 node_modules 缓存损坏。如果你在项目里升级依赖后出现莫名其妙的“Exit code 1”,特别是 ESLint 或 TypeScript 版本跳变较大时,我建议先做一次干净的依赖重装:
bash复制rm -rf node_modules package-lock.json
npm install
不要迷信 npm ci 能解决一切,它在 lockfile 完整的情况下确实更快,但如果 lockfile 本身和 package.json 不同步,同样会出问题。
还有一个高频场景:lint-staged 里配置了 tsc --noEmit,但它检查的是整个项目,而不是暂存文件。当你的同事在别的分支留下一个类型错误,而这个错误在 merge 前没有修复干净,你提交时就会看到一个跟你改动毫无关系的报错。解决办法有两个方向:要么把类型检查拆成增量检查插件,要么接受“全量类型检查”作为项目门槛,让大家在合并前自己跑一遍。
5.4 速查表:常见错误信号与处理建议
| 现象 | 常见根因 | 处理建议 |
|---|---|---|
| 输出里明确出现 ESLint error | 代码风格/规则问题 | 修复后重新 add 再提交 |
| 输出里出现 Prettier 修改提示 | 格式未统一 | 让 prettier --write 生效后重新提交 |
| 输出为空但退出码 1 | 脚本本身失败或环境问题 | 手动执行钩子里的命令逐一排查 |
command not found |
PATH 环境变量缺失 | 检查 node_modules/.bin 是否在 PATH 中 |
| husky 初始化报错 | 依赖安装不完整 | 重新执行 npm install 和 husky install |
| Windows 下权限或解释器错误 | shell 环境异常 | 确认 Git Bash 可用并恢复 .sh 关联 |
| 无法解析某个 eslint 规则 | 依赖版本不一致 | 同步 eslint 插件版本并清缓存重装 |
| lint-staged 不处理文件 | 通配符匹配不上 | 检查暂存文件路径和 minimatch 规则是否匹配 |
表中的前两条是高频中的高频,处理起来也最简单。很多同学一看到 pre-commit 就发怵,其实只要记住:hooks 本身只是帮你在提交前强制把关,它报错了说明击中了问题,解决问题的方法永远先看错误内容,再看配置,最后才考虑环境。
我个人在这些年的项目里,其实越来越依赖 pre-commit 这套机制。它就像进机房前的安检门,拦下来总比让脏代码混进主干好。遇到 exited with code 1 时不用慌,按本文的顺序诊断:先看完整输出,定位失败命令,检查 husky 安装状态,修正代码或配置,再用 --allow-empty 做一次快速验证。这套流程走下来,绝大多数问题十分钟内就能收工。
