Watch at Ease세상을 읽고, 일상의 답을 찾다.

기술 · 실용 가이드

Git에서 브랜치가 다른 worktree에서 사용 중이라고 하나요? prune 또는 force 전에 읽기 전용 연결 원장을 만드세요

prune, remove, unlock, repair, force부터 실행하지 마세요. 각 worktree 경로, 브랜치 소유자, 양방향 연결, 잠금, 변경, 진행 중 작업과 dry-run 결과를 먼저 기록하세요.

Git worktree 소유권 읽기 전용 원장을 보여 주는 오리지널 개념 일러스트.
Watch at Ease 오리지널 개념 일러스트: 변경 전에 목록, 해석, 연결 검증, 보호와 미리보기를 수행합니다. Watch at Ease

브랜치가 이미 사용 중이라는 오류는 소유권 신호입니다. 소유자는 정상, 잠금, 이동, 오프라인, 변경 있음, 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를 허가하지 않습니다. 증거가 미해결이면 저장소 변경 전에 중지하세요.

worktree 분류를 안전한 다음 소유자에 연결하는 오리지널 결정표 개념 일러스트.
Watch at Ease 오리지널 개념 일러스트: 각 결과는 경계만 제시하고 자동 정리를 허가하지 않습니다. Watch at Ease