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,或正在合併與 rebase。

本文提供經隔離測試的跨平台 Node.js 臺帳:讀取穩定的 NUL 分隔資料、驗證雙向連結、只保留非敏感狀態,並在不變更儲存庫的情況下預覽 prune。

臺帳不授權清理。缺少路徑可能只是磁碟尚未掛載;髒狀態、進行中操作、連結不一致、讀取失敗或未知擁有者都必須停止。

先把錯誤視為擁有權訊號,而非清理指令

Git 刻意阻止同一普通分支同時在兩個 worktree 中簽出,因此訊息代表已有擁有者,並不允許刪除中繼資料或繞過保護。

先保存 common directory、所有路徑、分支 ref、HEAD 與 locked/prunable 原因;缺少路徑不代表記錄過期。

不要從破壞性動作開始;先建立唯讀臺帳,再由工作擁有者決定。

以穩定格式保存完整清單

`git worktree list --porcelain -z` 使用 NUL 分隔穩定欄位,不會被空白路徑或原因文字切斷。

臺帳也執行 `git worktree prune --dry-run -v`;結果只是證據,無法證明離線磁碟沒有重要資料。

保存完整輸出,不要只截取錯誤訊息。

驗證每個連結的兩端

連結 worktree 的 `.git` 指向 common directory 下的管理目錄,該目錄的 `gitdir` 再反向指回。

臺帳比較 top-level、absolute git-dir 與反向連結;不一致時先備份,再依正式文件 repair,不能手改。

主 worktree 的 `.git` 可為目錄,工具會正確區分。

保護變更、detached HEAD、鎖定與操作

臺帳只保存分支標頭、變更數量和 clean 狀態,不複製檔案內容;髒目錄必須停止。

detached HEAD 和鎖定可能是刻意安排,必須先確認擁有者與原因。

merge、rebase、cherry-pick、revert、bisect 或 sequencer 狀態都會停止自動化。

執行經測試的唯讀臺帳

腳本只呼叫 version、rev-parse、worktree list、symbolic-ref、status 與 prune dry-run,從不執行變更指令。

隔離測試以五個 worktree 驗證九項條件,涵蓋 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