기술 · 실용 가이드
Git에서 브랜치가 다른 worktree에서 사용 중이라고 하나요? prune 또는 force 전에 읽기 전용 연결 원장을 만드세요
prune, remove, unlock, repair, force부터 실행하지 마세요. 각 worktree 경로, 브랜치 소유자, 양방향 연결, 잠금, 변경, 진행 중 작업과 dry-run 결과를 먼저 기록하세요.

브랜치가 이미 사용 중이라는 오류는 소유권 신호입니다. 소유자는 정상, 잠금, 이동, 오프라인, 변경 있음, detached HEAD 또는 작업 진행 중 상태일 수 있습니다.
이 가이드는 NUL 구분 안정 형식을 읽고 연결 양쪽을 검증하며 비밀이 아닌 상태만 기록하고 저장소를 바꾸지 않은 채 prune을 미리 보는 테스트된 Node.js 원장을 제공합니다.
원장은 정리를 허가하지 않습니다. 사라진 경로는 마운트되지 않은 디스크일 수 있으며 변경, 작업, 연결 불일치, 읽기 실패나 알 수 없는 소유자는 중지 조건입니다.
오류는 정리 명령이 아니라 소유권 신호입니다
Git은 같은 일반 브랜치를 두 worktree에 동시에 체크아웃하지 못하게 합니다. 메시지는 기존 소유자의 증거이지 메타데이터 삭제나 보호 우회 허가가 아닙니다.
common directory, 모든 경로, 정확한 ref, HEAD, locked/prunable 이유를 보존하세요. 경로가 없다고 오래된 등록이라고 단정할 수 없습니다.
remove, prune, unlock, repair, force 또는 브랜치 삭제 전에 읽기 전용 원장을 만드세요.
안정 형식으로 전체 목록을 보존합니다
`git worktree list --porcelain -z`는 NUL 종결 안정 형식이므로 공백이 있는 경로나 이유도 안전하게 보존합니다.
`git worktree prune --dry-run -v`는 후보 증거일 뿐이며 마운트되지 않은 저장소의 미커밋 작업을 알 수 없습니다.
오류 한 줄이 아니라 전체 출력을 저장하세요.
각 연결의 양쪽을 검증합니다
연결 worktree의 `.git`는 common directory의 관리 디렉터리를 가리키고 그 안의 `gitdir`는 다시 worktree를 가리킵니다.
원장은 top-level, absolute git-dir와 역방향 링크를 비교합니다. 불일치하면 백업 후 문서화된 repair를 검토하고 파일을 직접 편집하지 않습니다.
기본 worktree의 `.git`가 디렉터리인 차이도 구분합니다.
변경, detached HEAD, 잠금과 작업을 보호합니다
원장은 브랜치 헤더, 변경 개수와 clean 플래그만 저장하고 파일 내용은 복사하지 않습니다. dirty면 중지합니다.
detached HEAD와 잠금은 의도적일 수 있으므로 소유자와 이유를 확인합니다.
merge, rebase, cherry-pick, revert, bisect 또는 sequencer 상태는 자동화 중지 조건입니다.
테스트된 읽기 전용 원장을 실행합니다
스크립트는 version, rev-parse, worktree list, symbolic-ref, status, prune dry-run만 실행하며 변경 명령을 호출하지 않습니다.
격리 테스트는 5개 worktree와 9개 검증으로 dirty, detached, locked, prunable 및 양방향 연결을 확인했습니다.
```javascript #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; const startPath = path.resolve(process.argv[2] || process.cwd()); function runGit(cwd, args, { allowFailure = false } = {}) { const result = spawnSync("git", args, { cwd, encoding: "buffer", maxBuffer: 16 * 1024 * 1024, windowsHide: true, }); const stdout = result.stdout || Buffer.alloc(0); const stderr = result.stderr || Buffer.alloc(0); if (!allowFailure && result.status !== 0) { throw new Error( `git ${args.join(" ")} failed (${result.status}): ${stderr.toString("utf8").trim()}`, ); } return { ok: result.status === 0, status: result.status, stdout, stderr, }; } function gitText(cwd, args, options) { const result = runGit(cwd, args, options); return { ...result, text: result.stdout.toString("utf8").trim() }; } function canonical(input) { if (!input) return null; const absolute = path.resolve(input); try { return realpathSync.native(absolute); } catch { return absolute; } } function safeReadText(file) { try { return readFileSync(file, "utf8").trim(); } catch { return null; } } function parseWorktrees(buffer) { const fields = buffer.toString("utf8").split("\0"); const records = []; let current = null; for (const field of fields) { if (!field) continue; const space = field.indexOf(" "); const key = space === -1 ? field : field.slice(0, space); const value = space === -1 ? true : field.slice(space + 1); if (key === "worktree") { if (current) records.push(current); current = { worktree: value }; continue; } if (!current) throw new Error(`Unexpected porcelain field before worktree: ${field}`); current[key] = value; } if (current) records.push(current); return records; } function parseStatus(buffer) { const entries = buffer.toString("utf8").split("\0").filter(Boolean); const headers = entries.filter((entry) => entry.startsWith("# ")); const changes = entries.filter((entry) => !entry.startsWith("# ")); return { branchHeaders: headers, changeCount: changes.length, clean: changes.length === 0, }; } function inspectGitFile(worktreePath) { const dotGit = path.join(worktreePath, ".git"); if (!existsSync(dotGit)) return { kind: "missing", path: dotGit }; const stat = lstatSync(dotGit); if (stat.isDirectory()) return { kind: "directory", path: dotGit }; if (!stat.isFile()) return { kind: "other", path: dotGit }; const text = safeReadText(dotGit); const match = /^gitdir:\s*(.+)$/i.exec(text || ""); if (!match) return { kind: "invalid-file", path: dotGit, content: text }; const target = path.isAbsolute(match[1]) ? match[1] : path.resolve(worktreePath, match[1]); return { kind: "gitfile", path: dotGit, target: canonical(target) }; } function inspectExistingWorktree(worktreePath) { const gitDirResult = gitText(worktreePath, ["rev-parse", "--absolute-git-dir"], { allowFailure: true, }); const commonDirResult = gitText( worktreePath, ["rev-parse", "--path-format=absolute", "--git-common-dir"], { allowFailure: true }, ); const topResult = gitText(worktreePath, ["rev-parse", "--show-toplevel"], { allowFailure: true, }); const branchResult = gitText(worktreePath, ["symbolic-ref", "-q", "HEAD"], { allowFailure: true, }); const headResult = gitText(worktreePath, ["rev-parse", "--verify", "HEAD"], { allowFailure: true, }); const statusResult = runGit( worktreePath, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=normal"], { allowFailure: true }, ); const gitDir = gitDirResult.ok ? canonical(gitDirResult.text) : null; const commonDir = commonDirResult.ok ? canonical(commonDirResult.text) : null; const topLevel = topResult.ok ? canonical(topResult.text) : null; const gitFile = inspectGitFile(worktreePath); let backPointer = null; if (gitDir) { const backPointerPath = path.join(gitDir, "gitdir"); const raw = safeReadText(backPointerPath); if (raw !== null) { const resolved = path.isAbsolute(raw) ? raw : path.resolve(gitDir, raw); backPointer = { path: backPointerPath, target: canonical(resolved), matchesWorktreeGitFile: canonical(resolved) === canonical(path.join(worktreePath, ".git")), }; } } const operationPaths = [ "rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG", "sequencer", ]; const operations = []; for (const name of operationPaths) { const resolved = gitText(worktreePath, ["rev-parse", "--git-path", name], { allowFailure: true, }); if (resolved.ok && existsSync(resolved.text)) operations.push(name); } const linkage = { gitFile, backPointer, forwardMatchesResolvedGitDir: gitFile.kind !== "gitfile" || !gitDir ? null : gitFile.target === gitDir, topLevelMatchesListedPath: topLevel ? topLevel === canonical(worktreePath) : null, }; const linkageMismatch = [ linkage.forwardMatchesResolvedGitDir, linkage.topLevelMatchesListedPath, backPointer?.matchesWorktreeGitFile, ].includes(false); return { gitDir, commonDir, topLevel, branch: branchResult.ok ? branchResult.text : null, head: headResult.ok ? headResult.text : null, status: statusResult.ok ? parseStatus(statusResult.stdout) : null, operations, linkage, linkageMismatch, inspectionErrors: [ !gitDirResult.ok && "git-dir-unreadable", !commonDirResult.ok && "common-dir-unreadable", !topResult.ok && "top-level-unreadable", !statusResult.ok && "status-unreadable", ].filter(Boolean), }; } function classify(record, exists, detail) { if (record.locked) return "locked"; if (record.prunable) return "prunable"; if (!exists) return "missing-or-unmounted"; if (!detail || detail.inspectionErrors.length) return "ambiguous-inspection-failed"; if (detail.linkageMismatch) return "linkage-mismatch"; if (detail.operations.length) return "operation-in-progress"; return "active"; } const version = gitText(startPath, ["--version"]).text; const commonDir = canonical( gitText(startPath, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).text, ); const list = runGit(startPath, ["worktree", "list", "--porcelain", "-z"]); const records = parseWorktrees(list.stdout); const prunePreview = gitText(startPath, ["worktree", "prune", "--dry-run", "-v"], { allowFailure: true, }); const worktrees = records.map((record) => { const worktreePath = path.resolve(record.worktree); const exists = existsSync(worktreePath); const detail = exists ? inspectExistingWorktree(worktreePath) : null; return { path: worktreePath, listedHead: record.HEAD || null, listedBranch: record.branch || null, detached: Boolean(record.detached), bare: Boolean(record.bare), lockedReason: record.locked === true ? "(no reason supplied)" : record.locked || null, prunableReason: record.prunable === true ? "(no reason supplied)" : record.prunable || null, pathExists: exists, classification: classify(record, exists, detail), detail, }; }); const branchOwners = {}; for (const item of worktrees) { if (!item.listedBranch) continue; (branchOwners[item.listedBranch] ||= []).push(item.path); } const ledger = { schema: "git-worktree-readonly-ledger/v1", generatedAt: new Date().toISOString(), readOnlyCommands: [ "git --version", "git rev-parse ...", "git worktree list --porcelain -z", "git worktree prune --dry-run -v", "git symbolic-ref -q HEAD", "git status --porcelain=v2 -z --branch", ], repository: { startPath, commonDir, gitVersion: version }, prunePreview: { ok: prunePreview.ok, stdout: prunePreview.text, stderr: prunePreview.stderr.toString("utf8").trim(), }, branchOwners, worktrees, decisionBoundary: "This ledger makes no changes. Treat missing paths as possibly unmounted. Inspect and choose repair, unlock, remove, prune, or force only after confirming ownership and preserving uncommitted work.", }; process.stdout.write(`${JSON.stringify(ledger, null, 2)}\n`); ```
분류를 수정이 아닌 경계로 읽습니다
`active`면 기존 worktree를 사용하거나 명시적으로 인계하세요. `locked`는 이유 확인, `prunable`도 마운트와 백업 검토가 필요합니다.
`missing-or-unmounted`는 불확실, `linkage-mismatch`는 백업 후 repair, `operation-in-progress`는 즉시 중지입니다.
읽기 실패는 알 수 없음을 뜻하며 소유자가 없다는 증거가 아닙니다.
도구가 만든 worktree를 안전하게 처리합니다
GitHub CLI, Claude Code, Codex와 Git Town 사례는 도구가 기존 소유자를 무시할 수 있음을 보여 줍니다. 기존 경로 사용, 다른 브랜치 또는 정식 인계를 선택하세요.
`branchOwners`를 비교하고 일반 브랜치에 여러 경로가 나오면 원장을 보존한 뒤 중지하세요.
자동화 인계에는 정확한 common directory와 worktree 경로를 기록하세요.
검토 가능한 인계를 만듭니다
JSON, Git 버전, dry-run과 실패 명령에 소유자, 변경, 작업, 연결, 잠금, 사용할 수 없는 경로를 첨부합니다.
원장은 삭제 안전이나 백업을 증명하지 않고 force를 허가하지 않습니다. 증거가 미해결이면 저장소 변경 전에 중지하세요.
