Git Hook은 어디서 막고 어디서 통과시키는가
client-side 13종 hook의 실행 시점과 server-side pre-receive/update/post-receive의 stdin 구조부터 Husky + lint-staged 자동화까지, 정책 강제의 다층 방어를 추적한다.
- 01 Git이 파일이 아닌 SHA로 세상을 보는 이유
- 02 Git의 모든 포인터는 어디에 살고 있는가
- 03 Git은 파일 변경을 어떻게 추적하는가
- 04 Git history는 왜 DAG인가
- 05 Git Branch는 왜 이렇게 가벼운가
- 06 Git merge는 어떻게 충돌을 판단하는가
- 07 Rebase는 왜 새 commit을 만드는가
- 08 Git에서 변경을 되돌린다는 것의 의미
- 09 git stash는 어떻게 두 영역을 동시에 보존하는가
- 10 git push/fetch는 내부에서 무슨 일이 일어나는가
- 11 Git은 어떻게 실수를 복구하는가
- 12 Git Hook은 어디서 막고 어디서 통과시키는가
- 13 Submodule과 Subtree — 외부 레포 통합의 두 가지 철학
- 14 거대 레포는 어떻게 git을 견디는가
- 15 Git 워크플로우의 선택은 왜 팀 규모와 신뢰 수준에 달려 있는가
- 16 Git이 Push를 거부할 때 — 7가지 거부의 구조
Git hook은 “자동화 도구”라고 부르기엔 너무 낮은 위치에 있다. hook은 git 워크플로 자체에 박힌 인터셉트 포인트다. commit이 생기기 전, push가 서버에 닿기 전, ref가 갱신된 후 — 각 시점마다 쉘 스크립트 하나가 exit code로 작업을 막거나 통과시킨다. 그렇다면 13종 client hook과 3종 server hook은 정확히 언제 개입하고, --no-verify는 어디까지 무력화할 수 있는가?
Hook의 두 세계 — client와 server
client-side hook은 개발자 머신의 .git/hooks/ 안에 산다. commit 흐름을 따라가면 다음 순서로 호출된다.
git commit:
1. pre-commit 인자 없음, exit non-0이면 거부
2. prepare-commit-msg <msg-file> [<source>] [<SHA>]
3. (사용자 메시지 작성)
4. commit-msg <msg-file>, exit non-0이면 거부
5. (commit 객체 생성)
6. post-commit 인자 없음, 실패해도 commit 유지
git push:
1. pre-push stdin: <local-ref> <local-SHA> <remote-ref> <remote-SHA>
2. (서버로 객체 전송)
3. server-side hooks
post-checkout, post-merge, post-rewrite, pre-rebase 등 나머지 7종도 같은 원칙을 따른다. pre-*는 실패하면 작업을 막고, post-*는 실패해도 이미 끝난 작업을 되돌리지 않는다.
server-side hook은 다르다. git push가 서버에 도달하면 git-receive-pack이 세 hook을 순서대로 호출한다.
pre-receive stdin: <old> <new> <ref> (push한 모든 ref, 한 줄씩)
exit non-0 → 모든 ref 원자적 거부
update 인자: <ref> <old> <new> (ref 하나마다 한 번씩 호출)
exit non-0 → 그 ref만 거부
post-receive stdin: pre-receive와 동일
실패해도 ref 갱신은 이미 완료
pre-receive와 update의 차이가 핵심이다. pre-receive는 push된 모든 ref를 한 번에 검사하고 원자적으로 막는다. update는 ref별로 호출되므로 “release/* 브랜치는 admin만”처럼 ref 단위 정책을 쓸 때 적합하다.
--no-verify는 어디까지 통하는가
--no-verify가 우회하는 hook은 다음으로 한정된다.
우회 가능:
pre-commit
commit-msg
pre-push (--no-verify)
우회 불가:
post-* hooks (이미 작업 완료)
server-side hooks (전혀 다른 프로세스)
--no-verify는 git 자체 옵션이다. 개발자가 마음먹으면 언제든 client hook을 건너뛸 수 있다. commit message 컨벤션, secret 누출 방지, branch 보호를 반드시 강제하려면 server-side pre-receive에 동일 검증을 구현해야 한다.
server hook에서 거부 메시지를 client에게 전달하는 방법은 단순하다. stderr로 출력하면 client 콘솔에 remote: 접두사와 함께 표시된다.
#!/bin/sh
while read old new ref; do
if [ "$ref" = "refs/heads/main" ] && \
[ "$old" != "0000000000000000000000000000000000000000" ]; then
echo "Direct push to main not allowed!" >&2
exit 1
fi
done
client 출력:
remote: Direct push to main not allowed!
To <url>
! [remote rejected] main -> main (pre-receive hook declined)
자동화 패턴 — 무엇을 어디에 넣을 것인가
좋은 hook 자동화는 검사 위치를 계층별로 분리한다.
client pre-commit — 수 초 안에 끝나는 빠른 검사만. staged 파일의 lint, format 자동 수정.
#!/bin/sh
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
if [ -n "$files" ]; then
black --check $files || exit 1
fi
client commit-msg — Conventional Commits 패턴 검증.
#!/bin/sh
pattern='^(feat|fix|docs|style|refactor|test|chore)(\([a-z0-9-]+\))?: .{1,100}$'
if ! head -1 "$1" | grep -qE "$pattern"; then
echo "Commit message must follow Conventional Commits" >&2
exit 1
fi
server pre-receive — 모든 push에서 실행되는 강제 정책. 큰 파일 차단, secret 탐지, branch 보호.
#!/bin/sh
MAX_SIZE=10485760 # 10MB
while read old new ref; do
[ "$old" = "0000000000000000000000000000000000000000" ] && continue
git rev-list --objects "$old..$new" | while read sha path; do
[ -z "$sha" ] && continue
size=$(git cat-file -s "$sha" 2>/dev/null)
if [ "$size" -gt $MAX_SIZE ]; then
echo "File too large: $path ($size bytes)" >&2
exit 1
fi
done
done
server post-receive — ref 갱신 후 CI trigger, Slack 알림, 배포 자동화.
Husky + lint-staged — client hook의 자동 배포
팀 전체가 동일한 client hook을 쓰려면 hook 자체를 버전 관리해야 한다. Husky는 core.hooksPath를 .husky/로 설정해 이를 해결한다.
설치 흐름은 다음과 같다.
npm install husky --save-dev
npm pkg set scripts.prepare="husky install"
npx husky add .husky/pre-commit "npx lint-staged"
prepare script가 핵심이다. 신규 협업자가 npm install을 실행하면 post-install 단계에서 prepare가 자동으로 호출되고, husky install이 git config core.hooksPath .husky를 설정한다. .husky/ 디렉토리가 git에 커밋되어 있으므로 clone 직후 별도 안내 없이 hook이 활성화된다.
lint-staged는 staged 파일만 처리한다는 점에서 단순한 lint 호출과 다르다.
// package.json
{
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"],
"*.py": ["black", "flake8"]
}
}
실행 흐름: git diff --cached --name-only → 패턴 매칭 → 명령 실행 → 수정된 파일 자동 git add → 실패 시 exit 1. 전체 파일을 스캔하지 않으므로 레포가 커져도 commit 시간이 일정하게 유지된다.
트레이드오프
client hook은 빠른 피드백을 주지만 --no-verify로 우회된다. 환경 의존성도 있어 협업자 머신마다 다를 수 있다.
server hook은 절대 우회 불가능하고 환경이 일관되지만, 모든 push에서 실행되므로 성능이 중요하다. 5초짜리 server hook은 사용자 경험을 망친다 — 무거운 검사(full build, integration test)는 post-receive로 CI에 위임하고, pre-receive에는 밀리초 단위 검사만 넣어야 한다.
GitHub의 branch protection rules와 status checks는 이 server hook 계층의 추상화다. 자가 호스팅(GitLab, Gitea)은 hook을 직접 작성하고, GitHub Enterprise는 admin 전용 pre-receive hook을 제공한다.
정리
pre-*hook은 실패하면 작업을 막고,post-*hook은 실패해도 이미 완료된 작업을 되돌리지 않는다.--no-verify는pre-commit,commit-msg,pre-push만 우회한다. server-side hook은 client 옵션과 무관하다.- server
pre-receive는 모든 ref를 원자적으로 검사하고,update는 ref별로 호출된다. 정책 강제는 server에서 해야 한다. - Husky는
core.hooksPath를.husky/로 지정해 hook을 버전 관리하고,preparescript로 신규 협업자의 자동 onboarding을 처리한다. - lint-staged는 staged 파일만 처리하므로 레포 크기와 무관하게 commit 시간이 일정하다.
다음 글에서는 Git이 객체 그래프를 어떻게 추적하고, reflog와 GC가 어떤 관계인지 살펴본다.