Tecnología · GUÍA PRÁCTICA
¿Git dice que una rama ya está en otro worktree? Cree un registro de enlaces de solo lectura antes de prune o force
No empiece con prune, remove, unlock, repair ni force. Mapee primero rutas, dueños de ramas, enlaces de ida y vuelta, bloqueos, cambios, operaciones y el dry-run.

El error de rama ya utilizada es una señal de propiedad. El dueño puede estar sano, bloqueado, movido, fuera de línea, con cambios, en detached HEAD o en plena operación.
Esta guía ofrece un registro Node.js probado que lee el formato estable delimitado por NUL, valida ambos lados de cada enlace, guarda solo estado no secreto y previsualiza prune sin cambiar el repositorio.
El registro no autoriza la limpieza. Una ruta ausente puede ser un disco sin montar; cambios, operaciones, enlaces incoherentes, lecturas fallidas o dueño desconocido obligan a detenerse.
El error indica propiedad, no una orden de limpieza
Git impide deliberadamente que una rama normal esté activa en dos worktrees. El mensaje demuestra un dueño existente; no permite borrar metadatos ni eludir la protección.
Conserve el common directory, todas las rutas, el ref exacto, HEAD y los motivos locked/prunable. Una ruta ausente no demuestra que el registro sea obsoleto.
No empiece con remove, prune, unlock, repair, force ni el borrado de la rama. Cree primero el registro de solo lectura.
Capture la lista completa con el formato estable
`git worktree list --porcelain -z` usa campos terminados en NUL y conserva rutas con espacios y motivos completos.
El registro ejecuta también `git worktree prune --dry-run -v`; es evidencia, no permiso, porque Git no conoce el trabajo en un disco sin montar.
Guarde la salida completa, no solo una captura del error.
Valide ambos lados de cada enlace
El archivo `.git` de un worktree enlazado apunta al área administrativa del common directory y su archivo `gitdir` apunta de vuelta.
El registro compara top-level, absolute git-dir y backlink. Una discrepancia exige copia de seguridad y repair documentado, no edición manual.
El worktree principal puede tener un directorio `.git`; la herramienta distingue ese caso.
Proteja cambios, detached HEAD, bloqueos y operaciones
El registro guarda encabezados de rama, cantidad de cambios y estado clean sin copiar contenido. Un árbol dirty obliga a detenerse.
Detached HEAD y locked pueden ser intencionales; confirme dueño y motivo.
Merge, rebase, cherry-pick, revert, bisect o sequencer en curso detienen la automatización.
Ejecute el registro probado de solo lectura
El script solo invoca version, rev-parse, worktree list, symbolic-ref, status y prune dry-run; nunca ejecuta comandos de cambio.
La prueba aislada cubrió cinco worktrees y nueve aserciones: dirty, detached, locked, prunable y enlaces bidireccionales.
```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`); ```
Lea las clasificaciones como límites, no arreglos
`active` significa usar el worktree existente o hacer una entrega explícita. `locked` requiere revisar el motivo y `prunable` aún exige comprobar montaje y copias.
`missing-or-unmounted` sigue incierto; `linkage-mismatch` exige copia y repair; `operation-in-progress` obliga a parar.
Una lectura fallida significa desconocido, no ausencia de dueño.
Resuelva con seguridad worktrees creados por herramientas
Los casos de GitHub CLI, Claude Code, Codex y Git Town muestran herramientas que ignoran un dueño existente. Use la ruta existente, otra rama o una entrega revisada.
Compare `branchOwners`; si una rama normal aparece en varias rutas, conserve el registro y deténgase.
La entrega de automatización debe incluir common directory y ruta exactos.
Haga revisable la entrega
Adjunte JSON, versión de Git, dry-run y comando fallido, con dueño, cambios, operación, enlaces, bloqueo y rutas inaccesibles.
El registro no prueba que borrar sea seguro ni autoriza force. Si la propiedad o la evidencia siguen sin resolver, deténgase antes de cambiar el repositorio.
