Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 17 additions & 19 deletions .claude/hooks/guardBash.mjs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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/<slug> -b feat/<slug>)', 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/<slug> -b feat/<slug>)', 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)
Expand Down
62 changes: 62 additions & 0 deletions docs/2026/2026-04/2026-04-21/mainPrOnlyPolicy.md
Original file line number Diff line number Diff line change
@@ -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 <branch>` — 다른 브랜치로 이동
- 파일 읽기

### 금지 (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/<slug> -b feat/<slug>
cd .claude/worktrees/<slug>
# ... 작업, 커밋, 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`)
Loading