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
25 changes: 25 additions & 0 deletions .claude/hooks/allocWorktreePort.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import { readRegistry } from './worktreeRegistry.mjs'

const BASE = 5174
const RANGE = 30

function hashBranch(branch) {
let h = 2166136261
for (let i = 0; i < branch.length; i++) {
h ^= branch.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return Math.abs(h) % RANGE
}

export function allocPort(branch) {
const used = new Set(readRegistry().map((e) => e.port))
if (branch) {
const preferred = BASE + hashBranch(branch)
if (!used.has(preferred)) return preferred
}
let p = BASE
while (used.has(p)) p++
return p
}
19 changes: 13 additions & 6 deletions .claude/hooks/guardBash.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ const cmd = (input.tool_input?.command ?? '').trim()

const isMainBranch = () => {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim()
// 명령 앞의 `cd <path>`를 감지해 실제 실행 위치 기준으로 브랜치 판정
// (Claude 프로세스 cwd는 main repo로 고정이라, cd 없이 판정하면 worktree 작업도 main으로 오판)
const cdMatch = rawCmd.match(/(?:^|[;&])\s*cd\s+([^\s;&|]+)/)
const args = cdMatch ? ['-C', cdMatch[1], 'rev-parse', '--abbrev-ref', 'HEAD'] : ['rev-parse', '--abbrev-ref', 'HEAD']
const branch = execSync(`git ${args.map((a) => JSON.stringify(a)).join(' ')}`, { encoding: 'utf8' }).trim()
return branch === 'main' || branch === 'master'
} catch { return false }
}
Expand All @@ -31,15 +35,18 @@ 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 금지 (main 브랜치) — feature branch에서는 허용', 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 },
{ 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' },
]

for (const { pattern, reason, onlyMain } of BLOCKED) {
for (const { pattern, reason, onlyMain, envOverride } of BLOCKED) {
if (pattern.test(cmd)) {
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
35 changes: 35 additions & 0 deletions .claude/hooks/requireWorktree.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { findCurrent } from './worktreeRegistry.mjs'

const input = JSON.parse(readFileSync('/dev/stdin', 'utf8'))
const filePath = input.tool_input?.file_path ?? ''

function isMainWorktree() {
try {
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim()
return !gitDir.includes('/worktrees/')
} catch { return true }
}

function slugFromPath(p) {
const base = p.split('/').filter(Boolean).slice(-2).join('-') || `wt-${Date.now()}`
return base.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().slice(0, 40)
}

if (findCurrent()) process.exit(0)
if (!isMainWorktree()) process.exit(0)

const slug = slugFromPath(filePath)
const reason = [
'main worktree에서 직접 수정 금지. 병렬 세션 규약 위반.',
'',
'새 worktree 생성:',
` git worktree add .claude/worktrees/${slug} -b feat/${slug}`,
` cd .claude/worktrees/${slug}`,
'',
'또는 기존 worktree로 이동 후 다시 시도하세요. (pnpm wt:list)',
].join('\n')

process.stdout.write(JSON.stringify({ decision: 'block', reason }))
39 changes: 39 additions & 0 deletions .claude/hooks/sessionStartWorktree.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { spawn } from 'child_process'
import path from 'path'
import { findCurrent, upsert, cleanStale } from './worktreeRegistry.mjs'
import { allocPort } from './allocWorktreePort.mjs'

const input = JSON.parse(readFileSync('/dev/stdin', 'utf8'))
const sessionId = input.session_id ?? null

cleanStale()
let entry = findCurrent()

const branch = (() => {
try { return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim() } catch { return 'unknown' }
})()

if (!entry && branch !== 'main' && branch !== 'master') {
const toplevel = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
const name = toplevel.split('/').pop()
entry = { name, branch, path: toplevel, port: allocPort(branch), session_id: sessionId, dev_pid: null, started_at: new Date().toISOString() }
upsert(entry)
} else if (entry) {
upsert({ ...entry, session_id: sessionId })
}

try {
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim()
const mainRoot = path.resolve(path.dirname(gitCommonDir) || repoRoot)
spawn('node', [path.join(mainRoot, 'scripts/wtStart.mjs')], { cwd: mainRoot, detached: true, stdio: 'ignore' }).unref()
} catch {}

const msg = entry
? `worktree: ${entry.name} | branch: ${entry.branch} | dev port: ${entry.port}`
: `worktree 밖(main 추정). Edit 시 hook이 worktree 생성을 요구합니다. pnpm wt:list로 현황 확인.`

process.stdout.write(JSON.stringify({ systemMessage: msg }))
18 changes: 18 additions & 0 deletions .claude/hooks/stopRequireCommit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env node
import { execSync } from 'child_process'
import { findCurrent } from './worktreeRegistry.mjs'

const entry = findCurrent()
if (!entry) process.exit(0)

const dirty = (() => {
try { return execSync('git status --porcelain', { encoding: 'utf8' }).trim().length > 0 } catch { return false }
})()
const unpushed = (() => {
try { return execSync(`git log @{u}..HEAD --oneline 2>/dev/null || git log HEAD --oneline --not --remotes`, { encoding: 'utf8' }).trim().length > 0 } catch { return false }
})()

if (!dirty && !unpushed) process.exit(0)

const reason = `worktree ${entry.name}에 미커밋(${dirty ? 'dirty' : 'clean'}) 또는 미푸시(${unpushed ? 'yes' : 'no'}) 상태. /handoff로 마무리하세요.`
process.stdout.write(JSON.stringify({ systemMessage: reason }))
46 changes: 46 additions & 0 deletions .claude/hooks/worktreeRegistry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { execSync } from 'child_process'
import path from 'path'

const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim()
const absCommonDir = path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(gitCommonDir)
const repoRoot = path.dirname(absCommonDir)
export const REGISTRY_PATH = path.join(repoRoot, '.claude', 'worktrees.json')

export function readRegistry() {
if (!existsSync(REGISTRY_PATH)) return []
try { return JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')) } catch { return [] }
}

export function writeRegistry(entries) {
writeFileSync(REGISTRY_PATH, JSON.stringify(entries, null, 2))
}

export function cleanStale() {
const entries = readRegistry()
const alive = entries.filter((e) => existsSync(e.path)).map((e) => {
if (e.dev_pid == null) return e
try { process.kill(e.dev_pid, 0); return e } catch { return { ...e, dev_pid: null } }
})
writeRegistry(alive)
return alive
}

export function findCurrent() {
const cwd = process.cwd()
const toplevel = (() => {
try { return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim() } catch { return cwd }
})()
return readRegistry().find((e) => path.resolve(e.path) === path.resolve(toplevel)) ?? null
}

export function upsert(entry) {
const entries = readRegistry().filter((e) => e.name !== entry.name)
entries.push(entry)
writeRegistry(entries)
}

export function remove(name) {
writeRegistry(readRegistry().filter((e) => e.name !== name))
}
61 changes: 48 additions & 13 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/skillEvent.mjs start",
"timeout": 3000,
"async": true
}]
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/skillEvent.mjs start",
"timeout": 3000,
"async": true
}
]
},
{
"matcher": "Bash",
Expand Down Expand Up @@ -97,17 +99,40 @@
"timeout": 3000
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/requireWorktree.mjs",
"timeout": 2000
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/sessionStartWorktree.mjs",
"timeout": 2000
}
]
}
],
"PostToolUse": [
{
"matcher": "Skill",
"hooks": [{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/skillEvent.mjs end",
"timeout": 3000,
"async": true
}]
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/skillEvent.mjs end",
"timeout": 3000,
"async": true
}
]
},
{
"matcher": "Edit|Write|Bash",
Expand Down Expand Up @@ -230,7 +255,17 @@
"timeout": 5000
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/stopRequireCommit.mjs",
"timeout": 2000
}
]
}
]
}
}
}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ test-results
.superpowers/
.claude/agent-ops/
.claude/worktrees/
.claude/worktrees.json
.claude/launch.json
.claude/Caddyfile
scripts/dom-compare.txt
scripts/dom-dump.txt
scripts/dom-dump.json
Expand Down
Loading
Loading