Pre-commit hooks는 근본적으로 깨졌다
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
두 가지 문제가 발생했다:
- pre‑commit 훅이 Rust 파일이 전혀 포함되지 않은 커밋을 처리하지 못한다.
- 리베이스 중에 훅이 실행되었다, 즉 아직 재작성 중인 브랜치에서 실행된 것이다.
첫 번째 문제만 해결해도 충분하지 않다. 우리는 다른 사람들의 브랜치를 제어할 수 없기 때문이다. 그들은:
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 훅 작성 팁
- 작업 트리가 아니라 인덱스에서 실행하십시오. 위에서 설명한 대로입니다.³
- 빠르고 신뢰할 수 있는 검사만 추가하십시오.
- 네트워크에 접근하는 검사는 훅에 절대 포함하지 마세요.
- 느리고 최신 빌드 캐시가 필요하는 검사는 훅에 절대 포함하지 마세요.
- 자격 증명이나 로컬 서비스를 필요로 하는 검사는 훅에 절대 포함하지 마세요.
- 가능한 한 조용하게 동작하도록 하세요. 이 훅은 다른 여러 명령 안에 숨겨져 실행되며, 개발자가 훅이 실행될 것임을 모르는 경우가 많습니다. 중요한 출력이 진행 메시지에 가려지지 않도록 하세요.
- 훅을 자동으로 설정하지 마세요. 이를 신뢰성 있게 만든다고 주장하는 도구는 잘못된 것입니다. 신뢰성 있게 자동 설정할 방법은 없으며, 제가 겪은 고장 횟수는 셀 수 없을 정도입니다. 수동 설정 방법을 문서에 명확히 추가하고,
CONTRIBUTING문서에 눈에 띄게 표시해 주세요. (기여 문서는 이미 있나요?)
그리고
pre‑commit훅은 작성하지 마세요!
추가 관찰
- 이것은 충분히 큰 저장소에서는 정말 느리지만, 실제로 대체할 방법이 없습니다.
git stash는 git 인덱스 상태를 파괴합니다. - 커밋을 FUSE 파일 시스템으로 노출하는 유일한 VCS는 Sapling이며(그 Eden FS 개요 참고), Facebook 외부에서는 지원이 미흡합니다.
- 할 수 있는 최선은 전체 작업 복사본을 살펴보는 것을 포기하고, 한 번에 하나의 파일만 읽는 훅을 작성하는 것입니다.↩︎⁴
기본적으로 베어 rebase를 실행할 때는 이런 일이 발생하지 않지만, --interactive를 추가하는 순간 거의 모든 작업이 훅을 실행합니다. 훅은 병합 충돌을 해결하려 할 때도 실행됩니다.↩︎²
차이에 대한 자세한 정보와 가능한 훅 전체 목록은 man 5 githooks를 참고하십시오.↩︎¹
제가 “변경된 파일에만 실행”이라고 말하지 않는 이유는 현재 커밋이 어떤 브랜치를 기반으로 하는지 신뢰성 있게 판단할 수 없기 때문입니다(관련 논의는 여기를 참고). 할 수 있는 최선은 가능성이 높은 임의의 브랜치를 선택하는 것입니다.↩︎³
각주
pre‑push훅은 거의 모든 문제를 회피합니다.- 병합 충돌을 해결하려 할 때도 훅이 실행됩니다.
- 위에서 설명한 대로 작업 트리가 아니라 인덱스에서 실행됩니다.
- 커밋을 FUSE 파일 시스템으로 노출하는 유일한 VCS는 Sapling입니다.