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,或正在执行合并与变基。

本文提供经过隔离测试的跨平台 Node.js 台账:读取稳定的 NUL 分隔 worktree 数据,校验双向链接,只记录非敏感状态证据,并在不改变仓库的前提下预览 prune。

台账不授权清理。缺失路径可能只是未挂载磁盘;脏状态、进行中操作、链接不一致、读取失败或所有者未知都必须停止。

先把错误当作所有权信号,而不是清理指令

Git 有意阻止同一普通分支同时在两个 worktree 中检出。因此,“分支已在别处使用”说明已有所有者,并不允许删除元数据或绕过保护。所有者可能是健康的链接 worktree、IDE 或自动化创建的目录、移动后的目录,或位于暂时离线存储上的锁定目录。

先保全身份:仓库 common directory、所有已登记路径、准确分支 ref、记录的 HEAD,以及 locked 或 prunable 原因。目录暂时不存在并不能证明记录陈旧。

不要从 remove、prune、unlock、repair、force checkout 或删除分支开始。先建立只读台账,再由工作所有者选择最小且经过审核的动作。

用稳定格式保存完整 worktree 列表

Git 将 `git worktree list --porcelain` 定义为稳定的机器可读格式;加上 `-z` 后,各字段以 NUL 结束,不会因路径空格或原因文本而被误切。

台账直接解析字节流,并运行 `git worktree prune --dry-run -v`。dry-run 只是证据;Git 无法知道暂时离线的存储中是否仍有重要的未提交文件。

保存完整输出,不要只截一张错误图或复制一个分支名。

校验每个 worktree 链接的两端

链接 worktree 的 `.git` 文件通常指向主仓库 `$GIT_COMMON_DIR/worktrees/<id>`;该管理目录的 `gitdir` 文件再反向指回 worktree 的 `.git`。

台账比较登记路径与 `--show-toplevel`,比较 `.git` 目标与 `--absolute-git-dir`,并校验反向链接。不一致需要备份和正式 repair 调查,不能手改文件。

主 worktree 的 `.git` 可以是目录,反向链接结构也不同;工具会准确区分。

保护未提交改动、detached HEAD、锁与进行中操作

对存在的每个路径,台账记录符号分支、HEAD 以及 `git status --porcelain=v2 -z --branch` 的摘要,只保存分支头、改动数量和 clean 标志,不复制文件内容。脏目录必须停止。

detached HEAD 可能用于审查、构建、bisect 或历史检查;锁也可能是为了保护可移动或网络存储。先确认所有者。

工具检查 merge、rebase、cherry-pick、revert、bisect 与 sequencer 状态;任何命中都停止自动化。

运行经过测试的只读台账

从仓库任意目录运行脚本,把 JSON 重定向到新的证据文件。它只调用 version、rev-parse、worktree list、symbolic-ref、status 和 prune dry-run,从不执行任何变更命令。

隔离测试覆盖五个 worktree 与九项断言,包括脏目录、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` 仍需先审查路径、挂载、备份和 dry-run;`missing-or-unmounted` 明确保持不确定;`linkage-mismatch` 需要备份后按文档 repair;`operation-in-progress` 立即停止。

任何读取失败都意味着未知,不能把“没看到所有者”当作没有所有者。

安全处理重复所有权与工具创建的 worktree

GitHub CLI、Claude Code、Codex 与 Git Town 的当前问题都展示了同一保护:工具假定要在当前目录检出分支,而另一个受管 worktree 已经拥有它。通常应连接现有目录、换分支或完成审核后的交接。

比较台账中的 `branchOwners` 与失败命令。普通分支应有一个登记所有者;若异常出现多个路径,保全台账并停止。

自动化交接应记录准确 common directory 和路径;另一个 clone 即使分支名相同,也属于不同所有权域。

让交接可审核,并把变更留在工具之外

在事件记录中附上 JSON 台账、Git 版本、prune dry-run 输出和失败命令,并写明所有者、脏状态、操作状态、链接结果、锁定或 prunable 原因和不可用路径。

台账不能证明删除安全,也不能证明所有未跟踪文件已有备份;它不会恢复离线存储、处理进行中操作或授权 force。只要所有权或证据未解决,就在改变仓库前停止。

把 worktree 分类对应到安全下一所有者的原创决策矩阵。
Watch at Ease 原创概念图:每个结果只给出边界,不自动授权清理。 Watch at Ease