From bcf630512e6f89e1ff152492652ebbdeba55dead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 21 Apr 2026 13:35:22 +0900 Subject: [PATCH] =?UTF-8?q?chore(hook):=20main=20=3D=20PR-only=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C=20+=20rawCmd=20=EC=B0=B8=EC=A1=B0=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main 브랜치에서 git commit/push/merge/rebase/cherry-pick 차단 - ALLOW_MAIN envOverride 제거 (하네스 env 미전파로 죽은 스위치였음) - rawCmd 참조 버그 수정 (origin/main에서 cmd로 rename되어 ReferenceError 발생하던 cd-prefix 판정 경로) - reset --hard, branch -D를 main-only로 축소 (feature branch 복구 허용) - push --force에 force-with-lease 부정 lookahead (대안 허용) - docs/mainPrOnlyPolicy.md: 허용/금지 매트릭스 + 워크플로우 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/guardBash.mjs | 36 +++++------ .../2026-04/2026-04-21/mainPrOnlyPolicy.md | 62 +++++++++++++++++++ 2 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 docs/2026/2026-04/2026-04-21/mainPrOnlyPolicy.md diff --git a/.claude/hooks/guardBash.mjs b/.claude/hooks/guardBash.mjs index 7a874406f..2e7828397 100644 --- a/.claude/hooks/guardBash.mjs +++ b/.claude/hooks/guardBash.mjs @@ -1,23 +1,17 @@ #!/usr/bin/env node /** - * PreToolUse:Bash hook — 비가역 git 명령 차단 + * PreToolUse:Bash hook — main = PR-only + 파괴적 git 명령 차단 * - * 차단 대상: - * - git stash (전체 원복) - * - git checkout . / git checkout -- . (전체 원복) - * - git restore . (전체 원복) - * - git clean -f (untracked 삭제) - * - git reset --hard (히스토리 파괴) - * - * CLAUDE.md 규칙: "어떤 경우든 git stash로 전체 원복 금지" + * main 브랜치는 로컬 쓰기 연산(commit/push/merge/rebase/cherry-pick) 전부 차단. + * feature branch/worktree에서는 허용. */ import { readFileSync } from 'fs' import { execSync } from 'child_process' const input = JSON.parse(readFileSync('/dev/stdin', 'utf8')) -const cmd = (input.tool_input?.command ?? '').trim() +const rawCmd = (input.tool_input?.command ?? '').trim() const isMainBranch = () => { try { @@ -35,18 +29,22 @@ const BLOCKED = [ { pattern: /\bgit\s+checkout\s+(\.|--\s*\.\s*$)/, reason: 'git checkout . 금지 — git checkout -- [파일명]으로 개별 파일만 원복하세요' }, { pattern: /\bgit\s+restore\s+\.\s*$/, reason: 'git restore . 금지 — git restore [파일명]으로 개별 파일만 원복하세요' }, { pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/, reason: 'git clean -f 금지 — rm [파일명]으로 개별 삭제하세요' }, - { pattern: /\bgit\s+reset\s+--hard\b/, reason: 'git reset --hard 금지 — git reset --soft 또는 git revert [커밋]을 사용하세요' }, - { pattern: /\bgit\s+push\s+[^|]*--force\b/, reason: 'git push --force 금지 — git push --force-with-lease를 사용하세요 (그래도 위험, 확인 필요)' }, - { pattern: /\bgit\s+push\s+[^|]*-f\b/, reason: 'git push -f 금지 — git push --force-with-lease를 사용하세요 (그래도 위험, 확인 필요)' }, - { pattern: /\bgit\s+branch\s+-D\b/, reason: 'git branch -D 금지 — git branch -d (소문자)를 사용하세요' }, - { pattern: /\bgit\s+commit\b/, reason: 'main 브랜치 commit 금지 — worktree에서 작업 (git worktree add .claude/worktrees/ -b feat/)', onlyMain: true, envOverride: 'ALLOW_MAIN' }, - { pattern: /\bgit\s+push\b/, reason: 'main 브랜치 push 금지 — PR 경로만 허용', onlyMain: true, envOverride: 'ALLOW_MAIN' }, + // 파괴적 명령은 main에서만 차단, feature branch/worktree에서는 허용 (복구용) + { pattern: /\bgit\s+reset\s+--hard\b/, reason: 'git reset --hard 금지 (main 브랜치) — feature branch/worktree에서는 허용', onlyMain: true }, + { pattern: /\bgit\s+push\s+[^|]*--force(?!-with-lease)\b/, reason: 'git push --force 금지 — git push --force-with-lease를 사용하세요' }, + { pattern: /\bgit\s+push\s+[^|]*\s-f\b/, reason: 'git push -f 금지 — git push --force-with-lease를 사용하세요' }, + { pattern: /\bgit\s+branch\s+-D\b/, reason: 'git branch -D 금지 (main 브랜치) — feature branch에서는 허용', onlyMain: true }, + // main = PR-only. 쓰기 연산 전부 worktree에서 수행 후 PR 경로로만 main에 도달. + { pattern: /\bgit\s+commit\b/, reason: 'main 브랜치 commit 금지 — worktree에서 작업 (git worktree add .claude/worktrees/ -b feat/)', onlyMain: true }, + { pattern: /\bgit\s+push\b/, reason: 'main 브랜치 push 금지 — PR 경로만 허용', onlyMain: true }, + { pattern: /\bgit\s+merge\b/, reason: 'main 브랜치 로컬 merge 금지 — worktree에서 머지 후 PR로 main 갱신', onlyMain: true }, + { pattern: /\bgit\s+rebase\b/, reason: 'main 브랜치 rebase 금지 — main은 PR-only', onlyMain: true }, + { pattern: /\bgit\s+cherry-pick\b/, reason: 'main 브랜치 cherry-pick 금지 — worktree에서 작업 후 PR', onlyMain: true }, ] -for (const { pattern, reason, onlyMain, envOverride } of BLOCKED) { - if (pattern.test(cmd)) { +for (const { pattern, reason, onlyMain } of BLOCKED) { + if (pattern.test(rawCmd)) { if (onlyMain && !isMainBranch()) continue - if (envOverride && process.env[envOverride] === '1') continue const output = JSON.stringify({ decision: 'block', reason }) process.stdout.write(output) process.exit(0) diff --git a/docs/2026/2026-04/2026-04-21/mainPrOnlyPolicy.md b/docs/2026/2026-04/2026-04-21/mainPrOnlyPolicy.md new file mode 100644 index 000000000..a755ed81b --- /dev/null +++ b/docs/2026/2026-04/2026-04-21/mainPrOnlyPolicy.md @@ -0,0 +1,62 @@ +--- +title: main = PR-only 정책 +date: 2026-04-21 +type: policy +status: active +tags: [worktree, git, hook, policy] +--- + +# main = PR-only + +main은 **로컬에서 쓰기 불가**. 모든 쓰기 연산은 worktree에서 수행하고, main 갱신 경로는 **GitHub PR 머지 + `git pull --ff-only`** 하나뿐이다. + +## Why + +- main이 로컬 commit/merge/rebase를 받으면 origin/main과 발산하기 쉽고 (직전 세션: 34 ahead / 5 behind 사례), PR 리뷰·CI·baseline 게이트가 우회된다. +- 훅이 "상황에 따라 예외"를 허용하면 (이전 `ALLOW_MAIN` envOverride) 에이전트가 우회를 학습한다. 죽은 스위치가 되거나, 살아있으면 게이트를 뚫는다. +- 격리를 코드로 강제하면 "어디서 작업하지?"에 항상 답이 있다 — worktree. + +## 허용/금지 매트릭스 (main worktree 기준) + +### 허용 +- `git fetch`, `git pull --ff-only` — PR 머지 후 동기화 +- `git log`, `git diff`, `git status`, `git show`, `git branch`, `git worktree ...` — 읽기 전용 +- `git checkout ` — 다른 브랜치로 이동 +- 파일 읽기 + +### 금지 (PreToolUse:Bash 훅이 차단) +- `git commit` +- `git push` +- `git merge` +- `git rebase` +- `git cherry-pick` +- 파일 쓰기(Edit/Write) — `requireWorktree.mjs`가 이미 차단 + +### 우회 수단: 없음 +`ALLOW_MAIN` envOverride를 제거했다. 작업하려면 worktree를 만든다. + +## 표준 워크플로우 + +### 신규 작업 +```bash +git worktree add .claude/worktrees/ -b feat/ +cd .claude/worktrees/ +# ... 작업, 커밋, push ... +gh pr create --base main +``` + +### 머지도 worktree에서 +main에 다른 브랜치를 머지해야 하면, **머지 전용 worktree**를 만든 뒤 거기서 머지하고 PR로 올린다. main 로컬에서 `git merge`는 차단된다. + +### main 갱신 +```bash +# PR 머지 후 +git checkout main +git pull --ff-only +``` + +## 훅 구성 + +- `.claude/hooks/guardBash.mjs` — 위 금지 명령을 패턴 매칭으로 차단 +- `.claude/hooks/requireWorktree.mjs` — main worktree에서 Edit/Write 차단 +- 둘 다 PreToolUse로 등록 (`.claude/settings.json`)