Technology · PRACTICAL GUIDE
Git says a branch is already used by another worktree? Build a read-only linkage ledger before prune or force
Do not begin with prune, remove, unlock, repair, or force. Map every worktree path, branch owner, backlink, lock, dirty state, operation, and dry-run result first.

A branch-already-used error is an ownership signal. The owner may be healthy, locked, moved, offline, dirty, detached, or in the middle of an operation.
This guide provides a tested, cross-platform Node.js ledger that reads stable NUL-delimited worktree data, validates both sides of every link, records only non-secret status evidence, and previews prune without changing the repository.
The ledger never authorizes cleanup. A missing path may be an unmounted disk, and any dirty state, operation, link mismatch, failed read, or unknown owner is a stop condition.
The error is an ownership signal, not a cleanup instruction
Git intentionally refuses to check out one branch in two ordinary worktrees. The message that a branch is already used elsewhere is therefore evidence of an existing owner, not permission to delete metadata or bypass the guard. The owner may be a healthy linked worktree, an IDE-created tree, an automation checkout, a moved directory, a locked tree on removable storage, or a registration whose path is temporarily unavailable.
Begin by preserving identity: the repository common directory, every listed path, the exact branch ref, the recorded HEAD, and whether Git marks the worktree locked or prunable. A plain directory listing cannot answer all of those questions, and a missing path does not prove the registration is stale when a network share, external disk, container mount, or another host may be offline.
Do not start with remove, prune, unlock, repair, force checkout, or branch deletion. Build a read-only ledger first, then let the owner of the affected work decide the smallest reviewed action.
Capture the stable worktree list without losing spaces or reasons
Git documents `git worktree list --porcelain` as a stable machine-readable format. Adding `-z` terminates attributes with NUL bytes, so paths, lock reasons, and prunable reasons are not broken by whitespace or line parsing. Each record can carry a path, HEAD, branch, detached state, bare state, lock reason, or prunable reason.
The companion ledger parses that byte stream directly. It also runs `git worktree prune --dry-run -v`, which reports what a real prune would consider without deleting anything. The dry-run output is evidence only: it is not an instruction to proceed, because Git cannot know that a currently absent mount contains uncommitted files that matter to you.
Save the complete output. A screenshot of one error or a copied branch name loses the relationship among path, ref, HEAD, reason, and repository identity.
Validate both sides of every worktree link
A linked worktree normally has a `.git` file that points into the main repository’s `$GIT_COMMON_DIR/worktrees/<id>` administration directory. That administration directory in turn has a `gitdir` file pointing back to the worktree’s `.git` file. The repository-layout documentation describes this two-way relationship, while `git rev-parse` provides the resolved Git directory, common directory, top-level path and per-worktree state paths.
The ledger compares the listed path with `--show-toplevel`, compares the worktree `.git` target with `--absolute-git-dir`, and checks the reverse `gitdir` backlink. A mismatch is a repair investigation, not a reason to hand-edit files. Preserve a backup and use the documented `git worktree repair` route only after the real location and owner are known.
The main worktree is different: its `.git` can be a directory and it may not have the same reverse backlink. The tool records that distinction instead of declaring the main worktree broken.
Protect dirty files, detached HEADs, locks and operations
For every path that exists, the ledger records the symbolic branch when one exists, the verified HEAD, and `git status --porcelain=v2 -z --branch`. It stores only branch headers, a change count, and a clean/dirty flag; it does not copy file contents. A dirty tree is a stop condition because removal or recreation could discard uncommitted work that is not represented by any commit.
Detached HEAD is not automatically an error. It may be intentional for review, build, bisect, or historical inspection. Record the commit and ask the owner before attaching or deleting anything. A lock is also intentional until proven otherwise; Git supports a reason precisely so worktrees on removable or network storage are not pruned merely because the path is absent.
The tool checks per-worktree paths for merge, rebase, cherry-pick, revert, bisect, and sequencer state. Any such operation is an immediate stop. Finish or abort that operation only in the correct worktree and under its owner’s direction.
Run the tested read-only ledger
Run the script from any directory inside the repository and redirect its JSON output to a new evidence file. It invokes Git without a shell and uses only version, rev-parse, worktree list, symbolic-ref, status, and prune dry-run commands. It never runs checkout, switch, remove, prune, unlock, repair, branch deletion, reset, clean, stash, commit, or any file mutation.
The isolated fixture test covered five worktrees and nine assertions: a healthy main tree, a dirty linked branch, a detached tree, a locked tree, and a removed path that Git marks prunable. It verified linkage, dirty-state detection, classification, one owner per fixture branch, and the dry-run evidence path.
```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`); ```
Read the classifications as boundaries, not fixes
`active` means the path exists, its resolved links agree, no protected operation was detected, and inspection succeeded. If it owns the branch, use that worktree or coordinate a deliberate branch handoff; do not force a second checkout merely to silence the error.
`locked` means Git has an explicit retention signal. Confirm the reason and owner. `prunable` means Git would consider the administrative record removable, but review the path, mount, backups, dirtiness evidence, and dry-run reason first. `missing-or-unmounted` is deliberately ambiguous. `linkage-mismatch` calls for backup and documented repair. `operation-in-progress` means stop automation.
If inspection fails, treat the result as unknown. Permissions, filesystem errors, unsupported Git versions, or an unavailable mount can hide decisive evidence. The absence of a visible owner is trustworthy only when every relevant read completed.
Resolve duplicate ownership and tool-created worktrees safely
Current issue reports show the same guard surfacing in GitHub CLI, Claude Code, Codex, and Git Town workflows. The common mistake is to assume the requested branch must be checked out in the current directory even though another managed worktree already owns it. The safe branch is often to attach the tool to the existing tree, select another branch, or complete a reviewed handoff—not to remove and recreate the owner.
Compare `branchOwners` in the ledger with the failing command. A normal branch should have one listed worktree owner. If metadata unexpectedly lists more than one path, preserve the entire ledger and stop; do not choose an owner by whichever directory was opened most recently.
For automation, record the exact repository common directory and worktree path in the job handoff. A path from another clone or another common directory is a different ownership domain even when the branch name is identical.
Make the handoff reviewable and keep mutation outside the tool
Attach the JSON ledger, the Git version, the prune dry-run output, and the failing command to the incident. Name the branch owner, the dirty/operation result, the linkage result, the lock or prunable reason, and any unavailable path. That gives a human owner enough evidence to choose among continued use, a clean branch handoff, storage remount, backup, documented repair, or eventual cleanup.
The ledger does not prove that deletion is safe, that every untracked file is backed up, or that an offline path is abandoned. It does not inspect another host, recover missing storage, resolve an in-progress operation, or authorize force. Its value is the boundary: if ownership or evidence remains unresolved, stop before changing repository state.
