テクノロジー · 実用ガイド
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、失敗コマンドに、所有者、dirty、操作、リンク、ロック、利用不能パスを添付します。
台帳は削除安全性やバックアップを証明せず、forceを許可しません。未解決なら変更前に停止します。
