Pre-commit hooks는 근본적으로 깨졌다

발행: (2025년 12월 27일 오후 12:45 GMT+9)
13 min read

Source: Hacker News

번역하려는 전체 텍스트를 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다. 코드 블록, URL 및 마크다운 형식은 그대로 유지합니다.

새로운 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 훅

$ 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 훅은 작업 트리에서 실행되며 인덱스에서는 실행되지 않기 때문에 문제를 잡지 못했습니다. Git이 추적하고 있는 버전은 여전히 잘못된 포맷팅을 가지고 있음을 확인할 수 있습니다:

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

훅을 더 스마트하게 만들기

인덱스에 있는 모든 파일을 임시 디렉터리로 체크아웃하고 그곳에서 훅을 실행해 보겠습니다.

$ 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에 맞게 포맷되지 않았어요.
우리 스크립트는 레포 전체의 모든 파일을 대상으로 실행되기 때문에 커밋이 차단됩니다.

수정된 파일에만 훅 적용하기

$ 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(-)

좋아. 멋지네요.

리베이스와 빈 커밋 다루기

Let’s simulate an old PR that needs to be rebased onto main.

$ git checkout upstream/HEAD   # Simulate an old PR by checking out an old commit
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

Now we want to edit the commit message.

$ git rebase -i main   # Rebase this branch onto our main branch
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. 리베이스 중에 훅이 실행되었다, 즉 아직 재작성 중인 브랜치에서 실행된 것이다.

첫 번째 문제만 해결해도 충분하지 않다. 우리는 다른 사람들의 브랜치를 제어할 수 없기 때문이다. 그들은:

  • git commit --no-verify를 사용할 수 있다.
  • 아예 pre‑commit 훅을 설치하지 않았을 수도 있다.
  • 원래 훅을 통과했지만 리베이스 후에 실패하는 브랜치를 가질 수 있다(예: 훅이 cargo check를 실행하는 경우).

교훈

  • 작업 트리가 아니라 인덱스에서 훅을 실행한다.
  • 실제로 변경된 파일에만 검사를 제한한다.
  • 빈 파일 집합 및 리베이스 작업에 견고하도록 훅을 만든다.
  • 최종 안전망으로 서버 측 검사(예: CI)를 사용하는 것을 고려한다.

Source:

pre‑commit 훅이 문제인 이유

그들은 오래된 버전의 훅을 사용한 브랜치를 가지고 있었을 수 있습니다. 이 버전은 나중 버전만큼 많은 검사를 수행하지 않았습니다.
우리의 실제 선택지는 git rebase를 실행할 때마다 --no-verify 옵션을 붙이고, 리베이스 중 수정하는 각 커밋에 대해 git commit에도 같은 옵션을 붙이며, 리베이스 외부에서 수행하는 모든 git merge에도 적용하는 것입니다.

이는 pre‑commit 훅이 근본적으로 깨진 아이디어이기 때문입니다:

  • 코드는 고립된 상태로 존재하지 않습니다.
  • 개발자 머신에만 있는 로컬 커밋은 CI를 통과하지 못합니다.
  • 커밋이 반드시 코드가 배포 준비가 되었음을 의미하지도 않습니다—git stash에서는 pre‑commit 훅이 실행되지 않는 이유가 바로 그것입니다!

저는 git stash를 사용하지 않습니다; 대신 git commit을 사용해 스태시를 브랜치에 연결하고, 훅이 이 워크플로우를 완전히 깨뜨립니다.

pre‑commit 훅에는 다양한 다른 함정이 존재합니다. 여기에 거의 모든 pre‑commit 훅이 잘못 구현되어 워크트리에서 무작위로 실행되고, 느리거나 신뢰성이 떨어지는—또는 그 둘 다—이라는 사실을 더하면 충분합니다.

커밋을 만들기 직전에 무언가를 추가하려는 pre‑commit 훅에 대해서는 말할 필요도 없습니다. 제발 사용하지 마세요. 대신 pre‑push를 사용하세요.¹

pre‑push 훅은 이러한 문제 대부분을 회피합니다.

pre‑push 훅 작성 팁

  1. 작업 트리가 아니라 인덱스에서 실행하십시오. 위에서 설명한 대로입니다.³
  2. 빠르고 신뢰할 수 있는 검사만 추가하십시오.
    • 네트워크에 접근하는 검사는 훅에 절대 포함하지 마세요.
    • 느리고 최신 빌드 캐시가 필요하는 검사는 훅에 절대 포함하지 마세요.
    • 자격 증명이나 로컬 서비스를 필요로 하는 검사는 훅에 절대 포함하지 마세요.
  3. 가능한 한 조용하게 동작하도록 하세요. 이 훅은 다른 여러 명령 안에 숨겨져 실행되며, 개발자가 훅이 실행될 것임을 모르는 경우가 많습니다. 중요한 출력이 진행 메시지에 가려지지 않도록 하세요.
  4. 훅을 자동으로 설정하지 마세요. 이를 신뢰성 있게 만든다고 주장하는 도구는 잘못된 것입니다. 신뢰성 있게 자동 설정할 방법은 없으며, 제가 겪은 고장 횟수는 셀 수 없을 정도입니다. 수동 설정 방법을 문서에 명확히 추가하고, CONTRIBUTING 문서에 눈에 띄게 표시해 주세요. (기여 문서는 이미 있나요?)

그리고 pre‑commit 훅은 작성하지 마세요!

추가 관찰

  • 이것은 충분히 큰 저장소에서는 정말 느리지만, 실제로 대체할 방법이 없습니다. git stash는 git 인덱스 상태를 파괴합니다.
  • 커밋을 FUSE 파일 시스템으로 노출하는 유일한 VCS는 Sapling이며(그 Eden FS 개요 참고), Facebook 외부에서는 지원이 미흡합니다.
  • 할 수 있는 최선은 전체 작업 복사본을 살펴보는 것을 포기하고, 한 번에 하나의 파일만 읽는 훅을 작성하는 것입니다.↩︎⁴

기본적으로 베어 rebase를 실행할 때는 이런 일이 발생하지 않지만, --interactive를 추가하는 순간 거의 모든 작업이 훅을 실행합니다. 훅은 병합 충돌을 해결하려 할 때도 실행됩니다.↩︎²

차이에 대한 자세한 정보와 가능한 훅 전체 목록은 man 5 githooks를 참고하십시오.↩︎¹

제가 “변경된 파일에만 실행”이라고 말하지 않는 이유는 현재 커밋이 어떤 브랜치를 기반으로 하는지 신뢰성 있게 판단할 수 없기 때문입니다(관련 논의는 여기를 참고). 할 수 있는 최선은 가능성이 높은 임의의 브랜치를 선택하는 것입니다.↩︎³

각주

  1. pre‑push 훅은 거의 모든 문제를 회피합니다.
  2. 병합 충돌을 해결하려 할 때도 훅이 실행됩니다.
  3. 위에서 설명한 대로 작업 트리가 아니라 인덱스에서 실행됩니다.
  4. 커밋을 FUSE 파일 시스템으로 노출하는 유일한 VCS는 Sapling입니다.
Back to Blog

관련 글

더 보기 »

공허

초기 경험 안녕하세요. 이것은 제가 Rust 프로그래밍 언어와 얽힌 이야기를 시리즈로 만들고자 하는 첫 번째 글이 될 것입니다. 제가 좋아하는 만큼...