Pre-commit hooks 根本上已损坏

发布: (2025年12月27日 GMT+8 11:45)
11 min read

Source: Hacker News

Source:

开始一个新的 Rust 项目

$ mkdir best-fizzbuzz-ever
$ cd best-fizzbuzz-ever
$ cat  main.rs
fn main() { for i in 0.. {
    println ("fizzbuzz");
}}
EOF
$ git init
Initialized empty Git repository in /home/jyn/src/third-website/best-fizzbuzz-ever/.git/
$ git add main.rs
$ git commit --message fizzbuzz
[main (root-commit) 661dc28] fizzbuzz
 1 file changed, 4 insertions(+)
 create mode 100644 main.rs

很棒。现在假设我把它加入到不同语言的 fizz‑buzz 项目列表中,例如 这个项目。他们告诉我必须 使用正确的格式 并且 保持一致的风格。真是太无礼了。

用于格式化的 Pre‑Commit Hook

$ cat  pre-commit
#!/bin/sh
set -eu
for f in *.rs; do
  rustfmt --check "$f"
done
EOF
$ chmod +x pre-commit
$ ln -s ../../pre-commit .git/hooks/pre-commit
$ git add pre-commit
$ git commit --message "add pre-commit hook"
Diff in /home/jyn/src/third-website/best-fizzbuzz-ever/src/main.rs:1:
-fn main() { for i in 0.. {
-    println ("fizzbuzz");
-}}
+fn main() {
+    for i in 0.. {
+        println("fizzbuzz");
+    }
+}

很棒!现在把这个改动提交吧。

$ rustfmt main.rs
$ git commit --message "add pre-commit hook"
[main 3be7b87] add pre-commit hook
 1 file changed, 4 insertions(+)
 create mode 100755 pre-commit
$ git status
On branch main
Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git restore ..." to discard changes in working directory)
	modified:   main.rs

哎呀……我们已经修正了格式,但实际上并没有把改动暂存。
Pre‑commit hook 在 工作树 上运行,而不是在 索引 上运行,所以它没有捕获到这个问题。我们可以看到 Git 追踪的版本仍然保持错误的格式:

$ git show HEAD:main.rs
fn main() { for i in 0.. {
    println ("fizzbuzz");
}}

让 Hook 更智能

让我们把索引中的所有文件检出到临时目录并在那里运行 hook。

$ cat  pre-commit
#!/bin/sh
set -eu

tmpdir=$(mktemp -d --tmpdir "$(basename "$(realpath .)")-pre-commit.XXXX")
trap 'rm -r "$tmpdir"' EXIT
git checkout-index --all --prefix="$tmpdir/"
for f in $tmpdir/*.rs; do
  rustfmt --check "$f"
done
EOF
$ git add pre-commit
$ git commit --message "make pre-commit hook smarter"
Diff in /tmp/best-fizzbuzz-ever-pre-commit.ZNyw/main.rs:1:
-fn main() { for i in 0.. {
-    println ("fizzbuzz");
-}}
+fn main() {
+    for i in 0.. {
+        println("fizzbuzz");
+    }
+}

太好了!这捕获到了问题。

现在让我们把我们的 Rust 程序添加到 fizz‑buzz 程序集合中。

$ git add main.rs
$ git commit --message "make pre-commit hook smarter"
[main 3cb40f6] make pre-commit hook smarter
 2 files changed, 11 insertions(+), 4 deletions(-)

$ git remote add upstream https://github.com/joshkunz/fizzbuzz
$ git fetch upstream
remote: Enumerating objects: 222, done.
remote: Total 222 (delta 0), reused 0 (delta 0), pack-reused 222 (from 1)
Receiving objects: 100% (222/222), 29.08 KiB | 29.08 MiB/s, done.
Resolving deltas: 100% (117/117), done.
From https://github.com/joshkunz/fizzbuzz
 * [new branch]      master -> upstream/master

$ git rebase upstream
Successfully rebased and updated refs/heads/main.

最后的微调

$ sed -i '1i // Written by jyn' main.rs
$ git commit main.rs --message "mark who wrote fizzbuzz"
Diff in /tmp/best-fizzbuzz-ever-pre-commit.n1Pj/fizzbuzz-traits.rs:4:
 use std::iter;

 struct FizzBuzz {
-    from : i32
-  , to : i32
+    from: i32,
+    to: i32,
 }

 impl FizzBuzz {

嗯哼。仓库中已有的代码并没有按照 rustfmt 进行格式化。
我们的脚本会对仓库中的 每个 文件运行检查,所以它阻止了提交。

将 Hook 限制为仅处理已修改的文件

$ cat  pre-commit
#!/bin/sh
set -eu

files=$(git diff --name-only --cached --no-ext-diff --diff-filter=d)

tmpdir=$(mktemp -d --tmpdir "$(basename "$(realpath .)")-pre-commit.XXXX")
trap 'rm -r "$tmpdir"' EXIT

printf %s "$files" | tr '\n' '\0' | xargs -0 git checkout-index --prefix="$tmpdir/"
for f in $tmpdir/*.rs; do
  rustfmt --check "$f"
done
EOF
$ git commit main.rs pre-commit \
  --message "update main.rs; make pre-commit even smarter"
[main f2925bc] update main.rs; make pre-commit even smarter
 2 files changed, 5 insertions(+), 1 deletion(-)

好的。很棒。

处理 Rebase 与空提交

让我们模拟一个需要基于 main 进行 rebase 的旧 PR。

$ git checkout upstream/HEAD   # 通过检出旧提交来模拟旧 PR
HEAD is now at 56bf3ab Adds E to the README
$ echo 'fn main() { println!("this counts as fizzbuzz, right?"); }' > print.rs
$ git add print.rs
$ git commit --message "Add print.rs"
[detached HEAD 3d1bbf7] Add print.rs
 1 file changed, 1 insertion(+)
 create mode 100644 print.rs

现在我们想要编辑提交信息。

$ git rebase -i main   # 将此分支 rebase 到我们的 main 分支
reword 3d1bbf7 Add print.rs
# Rebase f2925bc..3d1bbf7 onto f2925bc (1 command)

问题

Error: file `/tmp/best-fizzbuzz-ever-pre-commit.p3az/*.rs` does not exist
Could not apply 3d1bbf7... Add print.rs

出现了两个问题:

  1. pre‑commit 钩子无法处理不包含任何 Rust 文件的提交。
  2. 钩子在我们进行 rebase 时运行,也就是在分支正处于重写过程中的时候运行。

仅仅修复第一个问题是不够的,因为我们无法控制其他人的分支。他们可能会:

  • 使用 git commit --no-verify
  • 根本没有安装 pre‑commit 钩子。
  • 有一个最初通过钩子检查的分支,但在 rebase 后失败(例如,钩子运行 cargo check)。

收获

  • 在索引(index)上运行钩子,而不是在工作树(working tree)上。
  • 将检查限制在实际变更的文件上。
  • 让钩子能够稳健地处理空文件集合和 rebase 操作。
  • 考虑使用服务器端检查(例如 CI)作为最终的安全网。

为什么 pre‑commit 钩子有问题

他们可能有一个分支使用了旧版本的钩子,而旧版本的检查项没有后来的多。
我们唯一真正的选择是每次运行 git rebase 时都加上 --no-verify,在 rebase 中对每一次提交都加上 git commit,甚至可能对每一次在 rebase 之外执行的 git merge 都如此。

这是因为 pre‑commit 钩子本质上是一个有缺陷的想法

  • 代码并不是孤立存在的。
  • 本地开发者机器上的提交从未经过 CI。
  • 提交甚至不一定意味着代码已经准备好发布——git stash 上不运行 pre‑commit 钩子是有原因的!

我不使用 git stash;我使用 git commit 让我的 stash 与分支关联,而钩子完全破坏了这种工作流。

还有很多 相关案例其他 的 “脚枪”。这甚至还不算 几乎所有的 pre‑commit 钩子都是以一种错误的方式实现的,盲目地在工作树上运行,且速度慢或不可靠——或者两者兼而有之。

别让我开始说那些试图向你即将提交的内容添加东西的 pre‑commit 钩子。请直接不要使用它们。改用 pre‑push 吧。¹

pre‑push 钩子几乎可以避免所有这些问题。

编写 pre‑push Hook 的技巧

  1. 在索引(index)上运行,而不是在工作树(working tree)上,如上所述。³
  2. 只添加快速且可靠的检查
    • 触及网络的检查绝不应放入 Hook 中。
    • 运行缓慢且需要最新构建缓存的检查绝不应放入 Hook 中。
    • 需要凭证或本地运行服务的检查绝不应放入 Hook 中。
  3. 尽可能保持安静。此 Hook 隐藏在一堆其他命令中运行,开发者往往不知道它会被触发。不要把重要输出隐藏在大量进度信息后面。
  4. 不要自动设置 Hook。任何声称可以可靠完成此操作的工具都是错误的。没有办法可靠地实现,它在我这儿被破坏的次数已经数不清。请仅在 CONTRIBUTING 文档中显著地提供手动设置的说明。(你们有贡献文档,对吧?)

并且不要编写 pre‑commit Hook!

其他观察

  • 在足够大的仓库上,这真的相当慢,但没有真正的替代方案。git stash 会破坏 git 索引状态。
  • 唯一提供其提交的 FUSE 文件系统的 VCS 是 Sapling(参见其 Eden FS overview),在 Facebook 之外的支持很差。
  • 你能做到的最好的办法是放弃查看整个工作副本,只编写一次读取单个文件的钩子。↩︎⁴

默认情况下,运行裸 rebase 时不会发生这种情况,但只要你添加 --interactive,几乎所有操作都会触发钩子。钩子在你尝试解决合并冲突时也会运行。↩︎²

欲了解更多关于差异的信息以及可能的钩子完整列表,请参见 man 5 githooks。↩︎¹

请注意,我并没有说“仅在更改的文件上运行”。那是因为 实际上无法可靠地确定当前提交基于哪个分支(参见此处讨论)。你能做到的最好的办法是挑选一个看起来可能的随机分支。↩︎³

脚注

  1. pre‑push 钩子几乎可以避免所有这些问题。
  2. 钩子在你尝试解决合并冲突时也会运行。
  3. 如上所述,在索引上运行,而不是在工作树上。
  4. 唯一提供其提交的 FUSE 文件系统的 VCS 是 Sapling。
Back to Blog

相关文章

阅读更多 »

虚空

早期经历 你好。这将是我的第一篇文章,希望它能成为我与 Rust 编程语言纠缠的系列文章之一。鉴于我……