团队花了不少功夫写了个 pre-commit hook,每次提交前跑 eslint 和密钥扫描,脚本也提交进了仓库并写进了 README。但新同事 clone 之后反馈:什么都没跑,ESLint 报错的代码照样能提交,毫无提示。这不是 bug,而是 Git 的设计:.git/ 目录从不被 clone、也从不进版本控制,所以放在 .git/hooks/ 里的 hook 只存在于当初创建它的那台机器上。Git 故意不让 hook 随 clone 传播——如果 clone 别人的仓库时就自动执行未经审查的代码,那是个安全漏洞。因此分发 hooks 必须有一个明确的激活步骤。
TL;DR——最快的修复
把 hook 移进一个受版本控制的目录(比如 .githooks/),然后让 Git 指向它:
git config core.hooksPath .githooks # 每个 clone 都要各自执行一次
chmod +x .githooks/* # hook 必须有可执行权限
对大多数团队来说,core.hooksPath 这一行就是全部修复。唯一的坑:它对每个 clone 都是本地的,不会随 git clone 一起带过来,所以必须把它自动化(prepare 脚本、Makefile target,或一个 setup 脚本——见 Step 3)。如果你用 npm 生态,直接装 husky v9,让 npm install 自动激活 hooks。
你属于哪一类?
| 现象 | 最可能的原因 | 跳到 |
|---|---|---|
ls .git/hooks/ 只看到 *.sample 文件 | hook 在受控目录里,但 Git 没指向它 | 原因 1、2 |
.git/hooks/ 里有 hook 文件但从不触发 | 没有可执行权限,或 CRLF shebang | 原因 3、6 |
.husky/ 存在但 hook 不触发 | 从没跑过 npm install / husky | 原因 4 |
| 本地能跑、CI 里从不跑 | CI 脚本或 alias 里有 --no-verify | 原因 5 |
git config core.hooksPath 没有输出 | 这个 clone 从没设过 hooksPath | 原因 2 |
常见原因
按命中率从高到低排列。
1. hook 在受控目录里,但 .git/hooks/ 始终是空的
仓库里有个 hooks/ 或 .githooks/ 目录,提交了 pre-commit、pre-push 脚本。开发者以为 clone 就等于激活了它们。并没有——默认情况下 Git 只看 .git/hooks/,从不看受控目录。
怎么判断:ls .git/hooks/ 只显示 .sample 文件。真正的 hook 在 hooks/ 或 .githooks/ 里,既没被软链接、也没被复制进 .git/hooks/。
2. 这个 clone 没设置 core.hooksPath
core.hooksPath(Git 2.9.0 引入,2016 年 6 月)让 Git 去某个受控目录而不是 .git/hooks/ 找 hook。但这个配置写在 .git/config 里,而 .git/config 永远不会随 git clone 传播。每个全新 clone 都从默认值(.git/hooks)开始,所以只要 setup 步骤没跑,受控目录里的 hook 就是隐形的。
怎么判断:在全新 clone 上 git config core.hooksPath 没有任何输出。
3. hook 文件没有可执行权限
hook 提交时没带可执行位。git clone 会保留 Git 里存的文件模式,于是 hook 落地后是个不可执行的文件。Git 会静默跳过不可执行的 hook——不报错,也没退出码。
怎么判断:ls -la .githooks/pre-commit(或 .git/hooks/pre-commit)。若权限是 -rw-r--r--(没有 x),hook 不会运行。用 git ls-files -s .githooks/pre-commit 确认 Git 里存的模式:100644 是不可执行,100755 才是可执行。
4. 用了 hook 管理工具(husky、lefthook)但 clone 后没安装
仓库用了 husky v9,它把 hook 放在 .husky/ 里,安装时通过把 core.hooksPath 设成 .husky/_ 来激活。而这个安装只在 npm install 跑 prepare 脚本时才发生。一个只 git clone 而没跑 npm install 的开发者(或者用了某个跳过 lifecycle 脚本的包管理器),就没有任何激活的 hook。
怎么判断:.husky/ 存在,package.json 里有 "prepare": "husky"。跑 git config core.hooksPath——若不是输出 .husky/_,说明这个 clone 里 husky 从没跑过。
注意:husky v9 改了语法。旧版 v8 的 "prepare": "husky install" 和 husky add 命令都已废弃;v9 用纯 "prepare": "husky",并要你手动在 .husky/ 里创建 hook 文件。继承下来、仍是旧语法的项目在全新安装时可能直接报错——重新跑 npx husky init 即可迁移。
5. --no-verify 被写进了 CI 脚本或 alias
某个 CI 流水线,或某人的 shell alias,给 git commit / git push 加了 --no-verify(或 -n),从而绕过所有客户端 hook。不用这个 alias 的开发者本地一切正常,但 CI 里 hook 完全不跑。
怎么判断:grep -rn "no-verify\|\-n\b" .github/ Makefile scripts/ ~/.gitconfig——任何 --no-verify 都在跳过 hook。也要查 IDE 设置:VS Code 的 git.allowNoVerifyCommit,以及 JetBrains 提交对话框里的「Run Git hooks」勾选框。
6. Windows 换行符破坏了 shebang
hook 在 Windows 上写就、以 CRLF 换行提交,或者某条 .gitattributes 规则在 checkout 时又把它转回了 CRLF。在 macOS 和 Linux 上,内核无法把 #!/bin/sh\r(行尾带回车符)解析成 shebang,于是拒绝执行该文件,通常报一个让人摸不着头脑的 bad interpreter 错误,或者干脆静默。
怎么判断:file .git/hooks/pre-commit 输出包含「with CRLF line terminators」。或者 head -c 12 .git/hooks/pre-commit | xxd 在 shebang 行尾看到 0d 0a(CRLF)。
最短修复路径
Step 1:把 hook 移进受控目录并让 Git 指向它
一次提交即可对全队生效的最快修复:
mkdir -p .githooks
git mv hooks/pre-commit .githooks/pre-commit # 如果你已经有 hooks/ 目录
chmod +x .githooks/*
git config core.hooksPath .githooks # 每个 clone 各自激活
git add .githooks
git commit -m "chore: track git hooks under .githooks/"
core.hooksPath 接受相对仓库根目录的路径,所以 .githooks 对每个开发者都能正确解析。它只为这个仓库覆盖默认的 .git/hooks/。
Step 2:修好可执行位和换行符(让 hook 真的能跑)
chmod +x .githooks/pre-commit .githooks/pre-push
git update-index --chmod=+x .githooks/pre-commit .githooks/pre-push # 把 +x 位存进 Git
# 若文件在 Windows 上编写,去掉 CRLF(macOS 的 BSD sed 要写成 sed -i '' )
sed -i 's/\r$//' .githooks/pre-commit .githooks/pre-push
git add .githooks
git commit -m "fix: make hook scripts executable with LF line endings"
git update-index --chmod=+x 这一步才能保证可执行位在下一个人 clone 时还在——单纯的 chmod 只改你的工作副本。
Step 3:把激活步骤自动化,谁都不用记
core.hooksPath 不随 clone 传播,所以把 setup 接到开发者本来就会跑的命令里。
npm/yarn/pnpm 项目里,prepare 脚本会在每次安装后自动执行:
// package.json
{
"scripts": {
"prepare": "git config core.hooksPath .githooks"
}
}
非 JS 项目可以加一个 Makefile target 或 setup 脚本,并在 CONTRIBUTING.md 里写明:
# scripts/setup.sh
#!/bin/sh
git config core.hooksPath .githooks
chmod +x .githooks/*
echo "Git hooks activated."
Step 4:或者正确安装 husky v9(npm 生态)
如果想要一个托管方案,husky v9 会在安装时替你设好 core.hooksPath:
npm install --save-dev husky
npx husky init # 给 package.json 加上 "prepare": "husky",并创建 .husky/pre-commit
# 把你的检查写进 .husky/pre-commit,例如: npx lint-staged
之后任何同事的 npm install 都会激活 hook。验证:
git config core.hooksPath # 应输出 .husky/_
ls .husky/ # 你的 hook 文件:pre-commit、pre-push……
更想要一个不依赖 Node 的单一二进制?lefthook 是常见的替代方案:配置写进 lefthook.yml,跑 lefthook install(它会往 .git/hooks/ 写入轻量脚本),并可选地加一个 prepare/postinstall 来自动跑 lefthook install。
Step 5:验证 hook 在运行
touch test-hook.txt && git add test-hook.txt
git commit -m "test hook activation"
pre-commit 的输出应出现在终端里。如果没有,直接执行 hook 把 Git 隐藏掉的错误暴露出来:
sh .githooks/pre-commit ; echo "exit code: $?"
非零退出码意味着这个 hook 会拦截提交;退出码 0(或无任何输出)意味着 hook 要么通过了、要么根本没跑——回头复查原因 3 和 6。
Step 6:从 CI 和共享配置里去掉 --no-verify
grep -rn "no-verify" .github/ .gitlab-ci.yml Makefile scripts/
# 删掉每一处的 --no-verify。更好的做法:把同样的检查做成显式的 CI 步骤,
# 这样即使本地 hook 被跳过,构建照样会失败。
预防建议
- 把 hook 提交到受控目录(
.githooks/),并通过prepare脚本、Makefiletarget 或 husky/lefthook 安装来自动跑git config core.hooksPath .githooks——绝不要指望开发者去读 README。 - 用
git update-index --chmod=+x .githooks/*把可执行位存进 Git,这样+x才能在以后每次 clone 中存活。 - 提交
.gitattributes,写上.githooks/* text eol=lf(或*.sh text eol=lf),把 hook 脚本在所有平台(包括 Windows)锁定为 LF。 - 把每一项 pre-commit 检查(lint、密钥扫描、测试)都镜像成真正的 CI 步骤,这样带
--no-verify的提交照样会让构建失败。客户端 hook 是便利,不是强制边界——它们随时能被绕过。 - 让 pre-commit 检查保持快(控制在几秒内),开发者才不会动用
--no-verify。 - 在
CONTRIBUTING.md和 onboarding checklist 里加一条「clone 之后跑npm install(或make setup)以激活 Git hooks」。
常见问答 (FAQ)
Q: 有没有办法让 hook 在 clone 时零步骤自动激活?
A: 原生没有,而且这是故意的——Git 不会自动跑受控目录里的 hook,否则 clone 一个仓库就会执行任意代码。最接近的变通是 git config --global init.templateDir ~/.git-templates,把 hook 放进 ~/.git-templates/hooks/;之后你在这台机器上 clone 的新仓库,Git 会把这个模板复制进它的 .git/。但它只对配置过模板的机器上的新 clone 生效,且模板更新不会回灌到已有仓库——所以对团队来说,prepare 脚本或 husky 仍然更可靠。
Q: hook 在 macOS 上能跑,但在我们基于 Docker 的 CI 里不跑,为什么?
A: CI 镜像 clone 了仓库但从没跑激活步骤,所以 core.hooksPath 是空的。在 Dockerfile 或 CI setup 里加上 git config core.hooksPath .githooks && chmod +x .githooks/*。但实践中更推荐把同样的 lint/测试命令做成显式 CI 步骤,而不是在 CI 里依赖 hook。
Q: git config core.hooksPath 没输出——这就是问题所在吗?
A: 大概率是。空值意味着 Git 在用默认的 .git/hooks/,而你受控的 hook 不在那儿。设置它(git config core.hooksPath .githooks)并确认它现在能回显路径。如果你用 husky,预期它输出 .husky/_ 而不是 .husky。
Q: 我们用 Git LFS,改用 core.hooksPath 会不会破坏它?
A: 有可能。git lfs install 会往 .git/hooks/ 写它自己的 post-checkout、post-commit、post-merge、pre-push hook。一旦你把 core.hooksPath 重定向到 .githooks/,Git 就不再读 .git/hooks/,于是 LFS 的 hook 不再触发。要么跑 git lfs install --manual 并在你自己的 hook 里调用 git lfs 命令,要么让你 .githooks/ 里的脚本显式调用 LFS 的 hook。
Q: 怎么让某个 pre-commit 检查只对特定文件类型生效?
A: 在 hook 内部过滤已暂存的文件:git diff --cached --name-only --diff-filter=ACM | grep '\.ts$',仅当输出非空时才跑 linter。lint-staged(配合 husky)或 lefthook 的 glob: 过滤器能替你做这件事。
相关阅读
- 凭证 helper 锁住,pull / push 全失败
- 分支保护规则让我的 PR 合不了
- AI 把 secret 推到公开仓库了
- CRLF 转换让单次 commit 产生几千行 diff
- Submodule 怎么都拉不到最新 commit
- Claude Code commit 了 secret
标签: #git #version-control #排查