pre-push hook 자동 리뷰 — 사람이 까먹어도 돌게
push 직전에 백그라운드로 advisory 리뷰가 자동 실행되도록 git hook을 붙여요.
이 챕터를 끝내면 모든 push 직전에 advisory 리뷰가 자동으로 돌고, 결과가 push를 막지 않으면서 사용자에게는 보이는 hook을 만들 수 있어요.
왜 hook인가
슬래시 커맨드 안에 advisory 리뷰를 넣어도, 사람이 그 슬래시 커맨드를 호출 안 하면 의미가 없어요. push는 거의 모든 협업의 마지막 관문이라 여기에 hook을 걸면 빠뜨릴 수가 없어요.
조건은 두 가지:
- push 자체를 차단하지 않을 것 — advisory 원칙
- 결과가 사용자에게 보이긴 할 것 — 안 보이면 없는 거나 마찬가지
hook 파일 만들기 — git native
가장 단순한 방법은 git이 원래 제공하는 .git/hooks/pre-push를 그대로 쓰는 거예요. 의존성 0, 설정 0. 단, 시작 전 두 가지 사전 체크.
# 1) 기존 core.hooksPath 설정이 있으면 .git/hooks/는 무시돼요 — 그쪽 디렉토리를 써요
HOOK_DIR=$(git config --get core.hooksPath || echo .git/hooks)
echo "hook 디렉토리: ${HOOK_DIR}"
# 2) 그 자리에 pre-push 만들기
touch "${HOOK_DIR}/pre-push"
chmod +x "${HOOK_DIR}/pre-push"
HOOK_DIR이 .git/hooks 외 다른 경로로 나오면 그곳에 hook 본문을 두면 돼요. 새 레포면 보통 .git/hooks가 그대로 나옵니다.
그리고 다음을 그대로 붙여요:
#!/usr/bin/env bash
# .git/hooks/pre-push
# Advisory Codex review — never blocks push.
set -u
# Escape hatch: SKIP_CODEX=1 git push → advisory 완전히 끄기
if [[ "${SKIP_CODEX:-0}" == "1" ]]; then
echo "[pre-push] SKIP_CODEX=1, skipping advisory review"
exit 0
fi
# 베이스 브랜치는 env var 우선, fallback main → develop
resolve_base() {
if [[ -n "${CODEX_BASE_BRANCH:-}" ]]; then
echo "$CODEX_BASE_BRANCH"; return
fi
for candidate in origin/main origin/develop main develop; do
if git rev-parse --verify "$candidate" >/dev/null 2>&1; then
echo "$candidate"; return
fi
done
echo ""
}
BASE=$(resolve_base)
if [[ -z "$BASE" ]]; then
echo "[pre-push] no base branch found (tried: \$CODEX_BASE_BRANCH, origin/main, origin/develop, main, develop). skipping."
exit 0
fi
CHANGED=$(git diff --name-only "${BASE}...HEAD" 2>/dev/null)
if [[ -z "$CHANGED" ]]; then
echo "[pre-push] no changes vs ${BASE}, skipping advisory review"
exit 0
fi
# ── 시크릿 방어선 1: 파일 패턴 → 발견 시 push abort
# (이건 advisory가 아니라 결정론적 secret-scan 게이트예요. advisory의
# "never block"은 모델 출력에 적용되는 룰이고, 시크릿 파일 push는 그것과
# 별개의 보안 사고라 차단이 정답.)
SECRET_FILE_PATTERNS='(^|/)\.env($|\.|/)|\.(pem|key|p12|pfx|cer|crt)$|(^|/)secrets/|(^|/)credentials/|(^|/)private-key'
# 트래킹 가능한 예시/샘플/템플릿 파일은 false positive로 제외
SECRET_FILE_ALLOWLIST='\.(example|sample|template|dist)(\.[a-zA-Z0-9]+)?$'
SECRET_HITS=$(echo "$CHANGED" \
| grep -E "$SECRET_FILE_PATTERNS" \
| grep -E -v "$SECRET_FILE_ALLOWLIST" || true)
if [[ -n "$SECRET_HITS" ]]; then
echo "[pre-push] ⛔ SECRET FILES detected — push aborted (deterministic"
echo " secret-scan gate, separate from advisory review):"
echo "$SECRET_HITS"
echo "If this is intentional: SKIP_CODEX=1 git push (review your secret hygiene first)"
exit 1
fi
# ── 시크릿 방어선 2: 인라인 정규식 → 경고만, 진행
# `\s`는 BSD grep에서 false negative 가능 → POSIX `[[:space:]]*` 사용
DIFF_TEXT=$(git diff "${BASE}...HEAD" -- \
'*.ts' '*.tsx' '*.js' '*.jsx' '*.py' '*.sql' '*.go' '*.rs' \
2>/dev/null)
INLINE_PATTERNS='(api[_-]?key|secret|password|token)[[:space:]]*[:=][[:space:]]*['"'"'"][^'"'"'"]{16,}|Bearer[[:space:]]+[A-Za-z0-9._-]{20,}|sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}'
if echo "$DIFF_TEXT" | grep -E -i -q "$INLINE_PATTERNS"; then
echo "[pre-push] ⚠️ inline secret-like pattern detected in diff — advisory will still run, please review"
fi
echo "─────────────────────────────────"
echo " Advisory Codex Review (push proceeds regardless)"
echo " base: ${BASE}"
echo "─────────────────────────────────"
bash scripts/codex-review.sh --base "$BASE" --raw --timeout 90
echo "─────────────────────────────────"
exit 0
base 브랜치 결정 순서를 fallback 체인으로
origin/main 하드코딩은 다음에서 깨져요.
- 트렁크 브랜치명이
develop혹은trunk - release 브랜치(
release/2026-q2)에서 분기한 hotfix push - 신규 레포 / shallow clone에서
origin/main이 아직 없는 상태 - CI에서 origin remote가 다른 이름
해결: 결정 순서를 fallback 체인으로 깔아둬요.
CODEX_BASE_BRANCHenv var이 있으면 그걸 사용 —.envrc또는 CI variableorigin/main→origin/develop→main→develop순으로 첫 매칭 사용- 다 실패하면 advisory 자체를 스킵하고 push만 통과 (차단 X)
이름이 CODEX_BASE_BRANCH인 이유는 codex가 본인 ref를 받기 때문이에요(codex review --base "$BASE"). 우리가 prompt에 diff를 끼워 넣지 않고 codex에 위임하니까, ref 한 개만 잘 정해주면 끝나요.
시크릿 두 단계 방어선
advisory 리뷰는 diff를 외부 모델에게 보내는 작업이에요. hook 안에서 두 겹의 방어선을 쳐요.
1단계: 파일 패턴 push abort. .env*, *.pem, *.key, *.p12, *.pfx, *.cer, *.crt, secrets/, credentials/, private-key*가 diff에 잡히면 hook이 push 자체를 막아요(exit 1). 1챕터의 advisory 원칙("never block")은 모델 출력 결과에 적용되는 룰이고, 시크릿 파일 push는 그것과 별개의 보안 사고라 차단이 정답. 의도된 푸시면 SKIP_CODEX=1 git push로 우회. 위 hook은 .env.example/.env.sample/.env.template 같은 일반적으로 트래킹되는 파일은 사후 allowlist(SECRET_FILE_ALLOWLIST)로 빼서 false positive를 막아요.
2단계: 인라인 정규식 경고. diff 본문에 api_key=..., Bearer <20+>, sk-..., ghp_..., AKIA[0-9A-Z]{16} 같은 패턴이 잡히면 경고만 출력하고 advisory는 그대로 진행해요. 인라인 매치는 false positive가 많아서 차단까지 가면 push가 자주 막혀요. 정규식은 \s 대신 POSIX [[:space:]]*를 써서 BSD grep(macOS 기본)에서도 매치돼요.
이 두 단계로 commit 단계의 secret scanning(gitleaks/trufflehog)이 미설치된 환경에서도 최소한의 안전망이 됩니다.
포그라운드를 기본으로
위 예시는 포그라운드 실행이에요. 60~90초 기다리지만 결과를 그 자리에서 봐요. push 직후 결과를 사용자가 같은 화면에서 보고, CRITICAL이 잡히면 바로 후속 조치할 수 있다는 게 유일한 진짜 이점이에요. 1인이든 팀이든 동일.
백그라운드(& disown)는 push 속도가 안 늦어지는 대신 결과 가시성을 통째 잃어요 — 다른 명령 출력에 묻히거나 사용자가 터미널을 떠난 뒤 떠서 못 보고 지나가요. 정말 백그라운드가 필요하면 결과를 고정된 파일에 남겨야 의미가 있어요.
# 백그라운드 변형 — 반드시 파일로도 남길 것
LOG=".git/codex-review.log"
( bash scripts/codex-review.sh --base "$BASE" --raw --timeout 90 | tee "$LOG" ) &
disown $! 2>/dev/null || true
이 변형을 쓰려면 push 직후 cat .git/codex-review.log를 확인하는 본인(또는 팀) 컨벤션이 같이 와야 해요. 컨벤션 없이 백그라운드만 켜면 사실상 "advisory를 안 켠 것"과 같아요. 처음 깔 땐 포그라운드로 시작하고, 본인 push 흐름이 명확해진 뒤에만 백그라운드를 고려해요.
출력을 advisory 톤으로 정리
hook 출력이 에러 메시지처럼 보이면 사용자가 push를 의심해요. 위 예시처럼 구분선과 "push proceeds regardless" 한 줄로 advisory임을 명시하고, 래퍼가 항상 ## [STATUS] ... 헤더로 시작하니 사용자가 첫 줄만 봐도 차단이 아님을 알 수 있어요.
CRITICAL/WARNING이 발견돼도 줄 앞에 [ADVISORY] 같은 프리픽스를 붙이는 컨벤션이 있으면 더 안전해요. 단, 래퍼 출력은 가공 없이 그대로 흘리는 게 디버깅이 편해서 보통은 헤더 한 줄로 충분해요.
hook이 advisory 원칙을 깨면 생기는 일
만약 리뷰 결과가 CRITICAL이면 exit 1로 push를 막도록 만들면 어떻게 될까요. 처음 며칠은 좋아 보이는데 곧 다음이 일어나요.
- Codex가 일시적으로 다운되면 본인(또는 팀 전원)의 push가 막힘
- false positive 한 번에 push 우회(
--no-verify) 습관이 생김 - 우회 습관이 들면 hook 자체가 무력화됨
advisory는 advisory로 두는 게 장기적으로 작동해요. 차단이 필요한 룰은 별도 CI 스텝으로, 결정론적 검사(린트·타입·테스트)만 차단으로.
hook을 다른 머신에서도 살리고 싶다면
.git/hooks/는 git이 트래킹 안 해서 본인 머신에서만 동작해요. 노트북 교체, 신규 머신, dotfiles 재설정 시 hook을 잃어요. 두 가지 방법 중 하나로 살려둬요.
- dotfiles에 hook 본문 백업 (1인 권장) —
dotfiles/git-hooks/codex-pre-push로 hook 본문을 저장하고, 새 레포에서cp ~/dotfiles/git-hooks/codex-pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push한 줄로 복원. 의존성 0 git config core.hooksPath ./hooks— 레포 안에 트래킹되는hooks/디렉토리를 만들고 git이 그쪽을 보게. 한 번 설정으로 끝. 팀 공유에도 그대로 작동(신규 합류자가 한 번git config만 실행하거나postinstall로 자동화)
핵심은 어떤 방법을 쓰든 hook 내용(advisory 원칙, 시크릿 방어선)은 동일하다는 점이에요. 도구 선택은 단순한 이전 매커니즘 차이일 뿐.
CI에서는 어떻게
로컬 hook은 사람마다 다르게 깔리니 CI에도 같은 advisory 리뷰를 한 번 더 도는 게 안전해요. CI 워크플로우에 동일 래퍼를 부르고, 결과를 PR 코멘트로 남겨요.
핵심은 동일하게 advisory — CI가 advisory 리뷰 결과로 red가 되면 안 됩니다. 결정론적 검사만 red로, 리뷰 결과는 info 코멘트로.
다음 챕터 예고
자동화는 다 만들어졌어요. 마지막 챕터에서 언제 어느 도구로 갈지 판단 기준과 비용 가이드를 정리해요.