@webpieces/repo-workflow-core 0.4.976 → 0.4.977

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"worktree-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/worktree-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { CLASSIFICATION_PRUNABLE, DeletableWorktree } from './merged-branch-verdicts';\nimport { Worktree, WorktreeService } from './worktrees';\n\n/**\n * The EXECUTOR for the dead-WORKTREE verdicts that merged-branches.ts has been computing all along.\n *\n * WHY this exists: `DeletableWorktree` was designed for reaping — it carries `path` (what\n * `git worktree remove` takes) AND `branch` (what `git branch -D` takes afterwards) precisely because\n * a reap is always those two steps in that order. The reaping was then never wired up. The verdicts\n * had exactly one consumer, branch-creation-guard, which used them only to BLOCK: at the worktree cap\n * it printed the reap commands and refused to create the next worktree. Nothing ever ran them.\n *\n * That composes into a deadlock, not merely a missing feature. A merged worktree HOLDS its branch, so\n * the branch lands in `keep` with \"checked out in worktree '<path>' — remove that worktree before\n * deleting the branch\". Nothing removes the worktree. Both accumulate forever, and the guard's only\n * remaining advice is to loosen its own cap — which is the failure the cap exists to prevent. Observed\n * twice in one day: a `wp-cleanup` run that spared three worktree-held branches with that exact line,\n * and another that spared seven.\n *\n * THE ORDER IS FIXED AND LOAD-BEARING: archive the branch as a tag → `git worktree remove <path>` →\n * `git branch -D <branch>`.\n * - Archive FIRST, and if it fails nothing is deleted. Same rule BranchArchiver already enforces for\n * branches: a branch we could not tag is a branch whose only copy would be the reflog.\n * - Remove the worktree BEFORE the branch, because git flatly refuses to delete a branch that is\n * still checked out somewhere.\n *\n * AND NEVER `--force`. Git refuses to remove a worktree with uncommitted changes or untracked files,\n * and that refusal is the entire safety property here: a worktree removal deletes real FILES, not just\n * a ref, and an untracked file is by definition something no archive tag captured. A failed removal is\n * reported and moved past, exactly as a failed branch delete already is.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One worktree and what happened to it.\nexport class ReapedWorktree {\n path: string;\n // The branch the worktree held, '' when it was detached.\n branch: string;\n // The branch's tip BEFORE anything was destroyed. '' when it could not be resolved (or detached).\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim — a refused removal is a thing a human must read.\n error: string;\n // The `archive/<date>/<branch>` tag written before the removal, or '' (policy 'delete', or detached).\n archiveTag: string = '';\n /**\n * Did the BRANCH delete also succeed? A worktree removal that succeeds while the branch delete\n * fails is a real, reportable half-state — the directory is gone, the branch is still there — and\n * collapsing it into `ok` would hide it. `ok` means the DIRECTORY is gone; this means the pair is.\n */\n branchDeleted: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(treePath: string, branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.path = treePath;\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one worktree reap. `spared` carries the worktrees we refused to touch, each with its\n * reason, for the same reason ReapResult does: a cleanup silent about what it did NOT remove reads as\n * \"there was nothing else\", when those are exactly the ones only a human can rule on.\n */\nexport class WorktreeReapResult {\n reaped: ReapedWorktree[];\n failed: ReapedWorktree[];\n spared: DeletableWorktree[];\n\n constructor(reaped: ReapedWorktree[], failed: ReapedWorktree[], spared: DeletableWorktree[]) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n }\n}\n\n// The two never-removable sets, kept apart so each refusal can name its own reason (data-only class\n// per CLAUDE.md — `primary` is the clone that owns .git, `current` is the tree wp-cleanup runs in).\nclass ProtectedPaths {\n primary: Set<string>;\n current: Set<string>;\n\n constructor(primary: Set<string>, current: Set<string>) {\n this.primary = primary;\n this.current = current;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a refused removal can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeReaper {\n // Defaulted like BranchReaper's collaborators, so the non-DI call sites can just\n // `new WorktreeReaper()` while inversify still injects the singletons from a container.\n constructor(\n private readonly worktrees: WorktreeService = new WorktreeService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Reap every worktree in `targets`, skipping any the safety rails refuse.\n *\n * `cwd` is passed in rather than read from `process.cwd()` here so the \"never remove the tree I am\n * standing in\" rule is testable and so a caller running from a subdirectory still gets the right\n * answer — the containing WORKTREE is what matters, not the exact directory.\n *\n * `targets` is caller-chosen on purpose. wp-cleanup passes the provably-dead ones unattended, and\n * passes human-approved probably-dead ones on a second call. The safety rails below apply to both:\n * no answer at a prompt can authorise removing your own cwd or the primary clone.\n */\n // eslint-disable-next-line @typescript-eslint/max-params\n reapWorktrees(\n repoRoot: string,\n cwd: string,\n verb: MutationVerb,\n targets: DeletableWorktree[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): WorktreeReapResult {\n // 'keep' means \"delete nothing, ever\" — the reap degrades to a pure report, exactly as it does\n // for branches, so a repo that opted out of destructive cleanup still SEES what would have gone.\n if (retention === BRANCH_RETENTION_KEEP) return new WorktreeReapResult([], [], targets);\n\n const protectedPaths = this.protectedPaths(repoRoot, cwd);\n const reaped: ReapedWorktree[] = [];\n const failed: ReapedWorktree[] = [];\n const spared: DeletableWorktree[] = [];\n\n for (const target of targets) {\n const refusal = this.refuseReason(target, protectedPaths);\n if (refusal !== '') {\n spared.push(new DeletableWorktree(\n target.path, target.branch, refusal, target.pr, false, target.classification));\n continue;\n }\n const outcome = this.removeOne(repoRoot, verb, target, retention);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n\n return new WorktreeReapResult(reaped, failed, spared);\n }\n\n /**\n * The two directories that must never be removed, resolved to absolute paths so a relative\n * `../foo` in a verdict can still be compared against them.\n *\n * - THE PRIMARY CLONE. It owns `.git`; `git worktree remove` cannot take it, and a caller who\n * somehow got it into a target list has a bug we must not execute.\n * - THE TREE WE ARE STANDING IN. Removing your own cwd mid-command deletes the files underneath\n * the running process — including, when the tooling is invoked by an agent, the checkout the\n * agent's next tool call will try to read. merged-branches.ts already declines to mark it\n * deletable; this is the second, independent line, because the caller supplies the list.\n */\n private protectedPaths(repoRoot: string, cwd: string): ProtectedPaths {\n const primary = new Set<string>();\n for (const tree of this.worktrees.listWorktrees(repoRoot)) {\n if (tree.isMain) primary.add(path.resolve(tree.path));\n }\n\n const here = new Set<string>();\n const current = this.currentTree(repoRoot, cwd);\n if (current !== null) here.add(path.resolve(current.path));\n // Fail SAFE when git could not name the current worktree: protect the raw paths anyway. An\n // over-protected path costs one worktree that survives to the next cleanup; an under-protected\n // one costs the directory the command is running in.\n here.add(path.resolve(repoRoot));\n here.add(path.resolve(cwd));\n return new ProtectedPaths(primary, here);\n }\n\n // The worktree record CONTAINING cwd — not merely the one whose path equals it, so `wp-cleanup`\n // run from `packages/whatever` inside a worktree still protects that worktree.\n private currentTree(repoRoot: string, cwd: string): Worktree | null {\n const here = path.resolve(cwd);\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(repoRoot)) {\n const root = path.resolve(tree.path);\n if (here !== root && !here.startsWith(root + path.sep)) continue;\n // Longest match wins: worktrees can nest, and the innermost one is the one we are in.\n if (best === null || root.length > path.resolve(best.path).length) best = tree;\n }\n return best;\n }\n\n // '' when the target may be reaped; otherwise the human-readable reason it was spared instead. The\n // two rails report SEPARATELY: \"you are standing in it\" and \"that is the primary clone\" are\n // different mistakes, and a message covering both tells the reader neither.\n private refuseReason(target: DeletableWorktree, protectedPaths: ProtectedPaths): string {\n if (target.path === '') return 'no path recorded for this worktree — nothing safe to remove';\n const resolved = path.resolve(target.path);\n if (protectedPaths.primary.has(resolved)) {\n return 'refused — that is the primary clone, which owns .git and is not removable';\n }\n if (protectedPaths.current.has(resolved)) {\n return 'refused — this command is running in that worktree; removing your own cwd is a self-destruct';\n }\n return '';\n }\n\n /**\n * One worktree: ARCHIVE the branch, REMOVE the directory, then DELETE the branch — and stop at the\n * first step that fails.\n *\n * The prunable case is genuinely different and is why the classification token rides along on the\n * verdict: the directory is ALREADY gone, so `git worktree remove` fails on it and the reap is\n * `git worktree prune`. There is also nothing to archive from a directory that no longer exists —\n * the branch itself is still archived, since it may well still hold the only copy of some work.\n */\n private removeOne(\n repoRoot: string,\n verb: MutationVerb,\n target: DeletableWorktree,\n retention: string,\n ): ReapedWorktree {\n // Tip first: after the branch is gone there is nothing left to resolve, and the audit line's\n // whole job is to record what was destroyed while it still exists.\n const sha = target.branch !== '' ? this.revParse(repoRoot, target.branch) : '';\n\n const archive = this.archiveBranch(repoRoot, target, retention, sha);\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, target, sha, archive);\n\n // A worktree left LOCKED by an agent that has since died: git refuses `worktree remove` while\n // the lock stands, so clearing it is part of the removal rather than a separate decision. The\n // decision was already made upstream — merged-branches.ts sets this ONLY for a lock whose pid\n // is provably gone, and a lock held by a live agent or by anything unrecognised never gets a\n // verdict that reaches here. A failed unlock is not reported on its own: the removal that\n // follows fails with git's own words, which is the message a human can actually act on.\n const unlockedOk = target.unlockBeforeRemove && target.classification !== CLASSIFICATION_PRUNABLE\n && this.capture(repoRoot, ['worktree', 'unlock', target.path]).ok;\n\n const removed = target.classification === CLASSIFICATION_PRUNABLE\n ? this.capture(repoRoot, ['worktree', 'prune'])\n : this.capture(repoRoot, ['worktree', 'remove', target.path]);\n if (!removed.ok) return this.removalFailed(repoRoot, verb, target, sha, archive, removed.err);\n\n // Only NOW may the branch go: git refuses while a worktree still holds it.\n const branchDeleted = target.branch === ''\n || this.capture(repoRoot, ['branch', '-D', target.branch]).ok;\n\n const result = new ReapedWorktree(\n target.path, target.branch, sha, target.reason, target.pr, true, '');\n result.archiveTag = archive.tag;\n result.branchDeleted = branchDeleted;\n\n // The audit line records the unlock too: overriding a `git worktree lock` is the one step here\n // a reader could be surprised by, so it must be in the log next to the SHA and the recover=.\n // Reads the RESULT rather than asserting the attempt: a log line claiming an unlock that git\n // refused would be the same species of untrue message this whole change is removing.\n const unlocked = unlockedOk ? 'unlocked stale agent lock, ' : '';\n this.log(repoRoot, verb, target, sha, archive.tag, branchDeleted\n ? `${unlocked}removed worktree and deleted branch (${target.reason})`\n : `${unlocked}removed worktree; branch '${target.branch}' survived (git refused the delete)`);\n return result;\n }\n\n // Archiving is skipped for a detached worktree (no branch to tag) and under retention 'delete'.\n // Both report ok=true with an empty tag: there is nothing to archive, which is not a failure.\n private archiveBranch(\n repoRoot: string, target: DeletableWorktree, retention: string, sha: string,\n ): ArchiveResult {\n if (target.branch === '' || retention !== BRANCH_RETENTION_ARCHIVE_TAG) {\n return new ArchiveResult('', sha, true, '');\n }\n return this.archiver.archive(repoRoot, target.branch);\n }\n\n // Archive refused ⇒ NOTHING is removed. The directory survives to the next cleanup, which is the\n // fail-safe direction: the alternative is deleting files whose branch has no permanent ref.\n // eslint-disable-next-line @typescript-eslint/max-params\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree, sha: string, archive: ArchiveResult,\n ): ReapedWorktree {\n const error = `not removed — could not archive its branch first: ${archive.error}`;\n this.log(repoRoot, verb, target, sha, '', `SKIPPED (${error})`);\n return new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, false, error);\n }\n\n /**\n * git refused to remove the directory — nearly always because it holds uncommitted changes or\n * untracked files. Reported with git's own words and moved past. We do NOT retry with `--force`:\n * an untracked file is work no archive tag captured, and `--force` is how a cleanup command turns\n * into a data-loss command. The branch is left alone too, since it is still checked out here.\n */\n // eslint-disable-next-line @typescript-eslint/max-params\n private removalFailed(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree, sha: string,\n archive: ArchiveResult, err: string,\n ): ReapedWorktree {\n const error = `git refused to remove it: ${err} (not forced — untracked or modified files are `\n + 'work nothing has archived; remove them or the worktree by hand)';\n this.log(repoRoot, verb, target, sha, archive.tag, `FAILED (${err})`);\n const result = new ReapedWorktree(\n target.path, target.branch, sha, target.reason, target.pr, false, error);\n result.archiveTag = archive.tag;\n return result;\n }\n\n // eslint-disable-next-line @typescript-eslint/max-params\n private log(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree,\n sha: string, archiveTag: string, outcome: string,\n ): void {\n const event = new BranchMutationEvent(verb, 'REAP_WORKTREE');\n event.fromBranch = target.branch;\n event.sha = sha;\n event.archiveTag = archiveTag;\n event.worktreePath = target.path;\n event.outcome = outcome;\n this.mutationLog.logBranchMutation(repoRoot, event);\n }\n\n /** The literal command that puts BOTH the directory and the branch back. */\n restoreCommand(target: ReapedWorktree): string {\n const ref = target.archiveTag !== '' ? target.archiveTag : target.sha;\n if (ref === '') return `git worktree add ${target.path} <ref>`;\n if (target.branch === '') return `git worktree add ${target.path} ${ref}`;\n return `git worktree add -b ${target.branch} ${target.path} ${ref}`;\n }\n\n private revParse(repoRoot: string, ref: string): string {\n const result = this.capture(repoRoot, ['rev-parse', ref]);\n return result.ok ? result.out : '';\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
1
+ {"version":3,"file":"worktree-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/worktree-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { GitProcessApi, GitProcessClient } from '@webpieces/tooling-common';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { CLASSIFICATION_PRUNABLE, DeletableWorktree } from './merged-branch-verdicts';\nimport { Worktree, WorktreeService } from './worktrees';\n\n/**\n * The EXECUTOR for the dead-WORKTREE verdicts that merged-branches.ts has been computing all along.\n *\n * WHY this exists: `DeletableWorktree` was designed for reaping — it carries `path` (what\n * `git worktree remove` takes) AND `branch` (what `git branch -D` takes afterwards) precisely because\n * a reap is always those two steps in that order. The reaping was then never wired up. The verdicts\n * had exactly one consumer, branch-creation-guard, which used them only to BLOCK: at the worktree cap\n * it printed the reap commands and refused to create the next worktree. Nothing ever ran them.\n *\n * That composes into a deadlock, not merely a missing feature. A merged worktree HOLDS its branch, so\n * the branch lands in `keep` with \"checked out in worktree '<path>' — remove that worktree before\n * deleting the branch\". Nothing removes the worktree. Both accumulate forever, and the guard's only\n * remaining advice is to loosen its own cap — which is the failure the cap exists to prevent. Observed\n * twice in one day: a `wp-cleanup` run that spared three worktree-held branches with that exact line,\n * and another that spared seven.\n *\n * THE ORDER IS FIXED AND LOAD-BEARING: archive the branch as a tag → `git worktree remove <path>` →\n * `git branch -D <branch>`.\n * - Archive FIRST, and if it fails nothing is deleted. Same rule BranchArchiver already enforces for\n * branches: a branch we could not tag is a branch whose only copy would be the reflog.\n * - Remove the worktree BEFORE the branch, because git flatly refuses to delete a branch that is\n * still checked out somewhere.\n *\n * AND NEVER `--force`. Git refuses to remove a worktree with uncommitted changes or untracked files,\n * and that refusal is the entire safety property here: a worktree removal deletes real FILES, not just\n * a ref, and an untracked file is by definition something no archive tag captured. A failed removal is\n * reported and moved past, exactly as a failed branch delete already is.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One worktree and what happened to it.\nexport class ReapedWorktree {\n path: string;\n // The branch the worktree held, '' when it was detached.\n branch: string;\n // The branch's tip BEFORE anything was destroyed. '' when it could not be resolved (or detached).\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim — a refused removal is a thing a human must read.\n error: string;\n // The `archive/<date>/<branch>` tag written before the removal, or '' (policy 'delete', or detached).\n archiveTag: string = '';\n /**\n * Did the BRANCH delete also succeed? A worktree removal that succeeds while the branch delete\n * fails is a real, reportable half-state — the directory is gone, the branch is still there — and\n * collapsing it into `ok` would hide it. `ok` means the DIRECTORY is gone; this means the pair is.\n */\n branchDeleted: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(treePath: string, branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.path = treePath;\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one worktree reap. `spared` carries the worktrees we refused to touch, each with its\n * reason, for the same reason ReapResult does: a cleanup silent about what it did NOT remove reads as\n * \"there was nothing else\", when those are exactly the ones only a human can rule on.\n */\nexport class WorktreeReapResult {\n reaped: ReapedWorktree[];\n failed: ReapedWorktree[];\n spared: DeletableWorktree[];\n\n constructor(reaped: ReapedWorktree[], failed: ReapedWorktree[], spared: DeletableWorktree[]) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n }\n}\n\n// The two never-removable sets, kept apart so each refusal can name its own reason (data-only class\n// per CLAUDE.md — `primary` is the clone that owns .git, `current` is the tree wp-cleanup runs in).\nclass ProtectedPaths {\n primary: Set<string>;\n current: Set<string>;\n\n constructor(primary: Set<string>, current: Set<string>) {\n this.primary = primary;\n this.current = current;\n }\n}\n\n// Internal: one git call's verdict, trimmed stdout, and stderr so a refused removal can be reported. Data only.\nclass CapturedOutput {\n constructor(readonly ok: boolean, readonly out: string, readonly err: string) {}\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeReaper {\n // Defaulted like BranchReaper's collaborators, so the non-DI call sites can just\n // `new WorktreeReaper()` while inversify still injects the singletons from a container.\n constructor(\n private readonly worktrees: WorktreeService = new WorktreeService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n private readonly git: GitProcessApi = new GitProcessClient(),\n ) {}\n\n /**\n * Reap every worktree in `targets`, skipping any the safety rails refuse.\n *\n * `cwd` is passed in rather than read from `process.cwd()` here so the \"never remove the tree I am\n * standing in\" rule is testable and so a caller running from a subdirectory still gets the right\n * answer — the containing WORKTREE is what matters, not the exact directory.\n *\n * `targets` is caller-chosen on purpose. wp-cleanup passes the provably-dead ones unattended, and\n * passes human-approved probably-dead ones on a second call. The safety rails below apply to both:\n * no answer at a prompt can authorise removing your own cwd or the primary clone.\n */\n // eslint-disable-next-line @typescript-eslint/max-params\n async reapWorktrees(\n repoRoot: string,\n cwd: string,\n verb: MutationVerb,\n targets: DeletableWorktree[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): Promise<WorktreeReapResult> {\n // 'keep' means \"delete nothing, ever\" — the reap degrades to a pure report, exactly as it does\n // for branches, so a repo that opted out of destructive cleanup still SEES what would have gone.\n if (retention === BRANCH_RETENTION_KEEP) return new WorktreeReapResult([], [], targets);\n\n const protectedPaths = this.protectedPaths(repoRoot, cwd);\n const reaped: ReapedWorktree[] = [];\n const failed: ReapedWorktree[] = [];\n const spared: DeletableWorktree[] = [];\n\n for (const target of targets) {\n const refusal = this.refuseReason(target, protectedPaths);\n if (refusal !== '') {\n spared.push(new DeletableWorktree(\n target.path, target.branch, refusal, target.pr, false, target.classification));\n continue;\n }\n const outcome = await this.removeOne(repoRoot, verb, target, retention);\n if (outcome.ok) reaped.push(outcome);\n else failed.push(outcome);\n }\n\n return new WorktreeReapResult(reaped, failed, spared);\n }\n\n /**\n * The two directories that must never be removed, resolved to absolute paths so a relative\n * `../foo` in a verdict can still be compared against them.\n *\n * - THE PRIMARY CLONE. It owns `.git`; `git worktree remove` cannot take it, and a caller who\n * somehow got it into a target list has a bug we must not execute.\n * - THE TREE WE ARE STANDING IN. Removing your own cwd mid-command deletes the files underneath\n * the running process — including, when the tooling is invoked by an agent, the checkout the\n * agent's next tool call will try to read. merged-branches.ts already declines to mark it\n * deletable; this is the second, independent line, because the caller supplies the list.\n */\n private protectedPaths(repoRoot: string, cwd: string): ProtectedPaths {\n const primary = new Set<string>();\n for (const tree of this.worktrees.listWorktrees(repoRoot)) {\n if (tree.isMain) primary.add(path.resolve(tree.path));\n }\n\n const here = new Set<string>();\n const current = this.currentTree(repoRoot, cwd);\n if (current !== null) here.add(path.resolve(current.path));\n // Fail SAFE when git could not name the current worktree: protect the raw paths anyway. An\n // over-protected path costs one worktree that survives to the next cleanup; an under-protected\n // one costs the directory the command is running in.\n here.add(path.resolve(repoRoot));\n here.add(path.resolve(cwd));\n return new ProtectedPaths(primary, here);\n }\n\n // The worktree record CONTAINING cwd — not merely the one whose path equals it, so `wp-cleanup`\n // run from `packages/whatever` inside a worktree still protects that worktree.\n private currentTree(repoRoot: string, cwd: string): Worktree | null {\n const here = path.resolve(cwd);\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(repoRoot)) {\n const root = path.resolve(tree.path);\n if (here !== root && !here.startsWith(root + path.sep)) continue;\n // Longest match wins: worktrees can nest, and the innermost one is the one we are in.\n if (best === null || root.length > path.resolve(best.path).length) best = tree;\n }\n return best;\n }\n\n // '' when the target may be reaped; otherwise the human-readable reason it was spared instead. The\n // two rails report SEPARATELY: \"you are standing in it\" and \"that is the primary clone\" are\n // different mistakes, and a message covering both tells the reader neither.\n private refuseReason(target: DeletableWorktree, protectedPaths: ProtectedPaths): string {\n if (target.path === '') return 'no path recorded for this worktree — nothing safe to remove';\n const resolved = path.resolve(target.path);\n if (protectedPaths.primary.has(resolved)) {\n return 'refused — that is the primary clone, which owns .git and is not removable';\n }\n if (protectedPaths.current.has(resolved)) {\n return 'refused — this command is running in that worktree; removing your own cwd is a self-destruct';\n }\n return '';\n }\n\n /**\n * One worktree: ARCHIVE the branch, REMOVE the directory, then DELETE the branch — and stop at the\n * first step that fails.\n *\n * The prunable case is genuinely different and is why the classification token rides along on the\n * verdict: the directory is ALREADY gone, so `git worktree remove` fails on it and the reap is\n * `git worktree prune`. There is also nothing to archive from a directory that no longer exists —\n * the branch itself is still archived, since it may well still hold the only copy of some work.\n */\n private async removeOne(\n repoRoot: string,\n verb: MutationVerb,\n target: DeletableWorktree,\n retention: string,\n ): Promise<ReapedWorktree> {\n // Tip first: after the branch is gone there is nothing left to resolve, and the audit line's\n // whole job is to record what was destroyed while it still exists.\n const sha = target.branch !== '' ? await this.revParse(repoRoot, target.branch) : '';\n\n const archive = await this.archiveBranch(repoRoot, target, retention, sha);\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, target, sha, archive);\n\n // A worktree left LOCKED by an agent that has since died: git refuses `worktree remove` while\n // the lock stands, so clearing it is part of the removal rather than a separate decision. The\n // decision was already made upstream — merged-branches.ts sets this ONLY for a lock whose pid\n // is provably gone, and a lock held by a live agent or by anything unrecognised never gets a\n // verdict that reaches here. A failed unlock is not reported on its own: the removal that\n // follows fails with git's own words, which is the message a human can actually act on.\n const unlockedOk = target.unlockBeforeRemove && target.classification !== CLASSIFICATION_PRUNABLE\n && (await this.capture(repoRoot, ['worktree', 'unlock', target.path])).ok;\n\n const removed = target.classification === CLASSIFICATION_PRUNABLE\n ? await this.capture(repoRoot, ['worktree', 'prune'])\n : await this.capture(repoRoot, ['worktree', 'remove', target.path]);\n if (!removed.ok) return this.removalFailed(repoRoot, verb, target, sha, archive, removed.err);\n\n // Only NOW may the branch go: git refuses while a worktree still holds it.\n const branchDeleted = target.branch === ''\n || (await this.capture(repoRoot, ['branch', '-D', target.branch])).ok;\n\n const result = new ReapedWorktree(\n target.path, target.branch, sha, target.reason, target.pr, true, '');\n result.archiveTag = archive.tag;\n result.branchDeleted = branchDeleted;\n\n // The audit line records the unlock too: overriding a `git worktree lock` is the one step here\n // a reader could be surprised by, so it must be in the log next to the SHA and the recover=.\n // Reads the RESULT rather than asserting the attempt: a log line claiming an unlock that git\n // refused would be the same species of untrue message this whole change is removing.\n const unlocked = unlockedOk ? 'unlocked stale agent lock, ' : '';\n this.log(repoRoot, verb, target, sha, archive.tag, branchDeleted\n ? `${unlocked}removed worktree and deleted branch (${target.reason})`\n : `${unlocked}removed worktree; branch '${target.branch}' survived (git refused the delete)`);\n return result;\n }\n\n // Archiving is skipped for a detached worktree (no branch to tag) and under retention 'delete'.\n // Both report ok=true with an empty tag: there is nothing to archive, which is not a failure.\n private async archiveBranch(\n repoRoot: string, target: DeletableWorktree, retention: string, sha: string,\n ): Promise<ArchiveResult> {\n if (target.branch === '' || retention !== BRANCH_RETENTION_ARCHIVE_TAG) {\n return new ArchiveResult('', sha, true, '');\n }\n return this.archiver.archive(repoRoot, target.branch);\n }\n\n // Archive refused ⇒ NOTHING is removed. The directory survives to the next cleanup, which is the\n // fail-safe direction: the alternative is deleting files whose branch has no permanent ref.\n // eslint-disable-next-line @typescript-eslint/max-params\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree, sha: string, archive: ArchiveResult,\n ): ReapedWorktree {\n const error = `not removed — could not archive its branch first: ${archive.error}`;\n this.log(repoRoot, verb, target, sha, '', `SKIPPED (${error})`);\n return new ReapedWorktree(target.path, target.branch, sha, target.reason, target.pr, false, error);\n }\n\n /**\n * git refused to remove the directory — nearly always because it holds uncommitted changes or\n * untracked files. Reported with git's own words and moved past. We do NOT retry with `--force`:\n * an untracked file is work no archive tag captured, and `--force` is how a cleanup command turns\n * into a data-loss command. The branch is left alone too, since it is still checked out here.\n */\n // eslint-disable-next-line @typescript-eslint/max-params\n private removalFailed(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree, sha: string,\n archive: ArchiveResult, err: string,\n ): ReapedWorktree {\n const error = `git refused to remove it: ${err} (not forced — untracked or modified files are `\n + 'work nothing has archived; remove them or the worktree by hand)';\n this.log(repoRoot, verb, target, sha, archive.tag, `FAILED (${err})`);\n const result = new ReapedWorktree(\n target.path, target.branch, sha, target.reason, target.pr, false, error);\n result.archiveTag = archive.tag;\n return result;\n }\n\n // eslint-disable-next-line @typescript-eslint/max-params\n private log(\n repoRoot: string, verb: MutationVerb, target: DeletableWorktree,\n sha: string, archiveTag: string, outcome: string,\n ): void {\n const event = new BranchMutationEvent(verb, 'REAP_WORKTREE');\n event.fromBranch = target.branch;\n event.sha = sha;\n event.archiveTag = archiveTag;\n event.worktreePath = target.path;\n event.outcome = outcome;\n this.mutationLog.logBranchMutation(repoRoot, event);\n }\n\n /** The literal command that puts BOTH the directory and the branch back. */\n restoreCommand(target: ReapedWorktree): string {\n const ref = target.archiveTag !== '' ? target.archiveTag : target.sha;\n if (ref === '') return `git worktree add ${target.path} <ref>`;\n if (target.branch === '') return `git worktree add ${target.path} ${ref}`;\n return `git worktree add -b ${target.branch} ${target.path} ${ref}`;\n }\n\n private async revParse(repoRoot: string, ref: string): Promise<string> {\n const result = await this.capture(repoRoot, ['rev-parse', ref]);\n return result.ok ? result.out : '';\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private async capture(repoRoot: string, args: string[]): Promise<CapturedOutput> {\n const result = await this.git.run(args, repoRoot);\n const err = result.stderr.trim();\n if (!result.ok) return new CapturedOutput(false, '', err !== '' ? err : 'git command failed');\n return new CapturedOutput(true, result.stdout.trim(), err);\n }\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import { CommandTimeouts } from './command-timeouts';
1
2
  /**
2
3
  * Whether a worktree is being worked in, and the reason in the words an operator gets shown.
3
4
  *
@@ -40,10 +41,15 @@ export declare class Worktree {
40
41
  constructor(path: string, branch: string, head: string, isMain: boolean, prunable: boolean, locked: boolean, lockReason: string);
41
42
  }
42
43
  export declare class WorktreeService {
44
+ private readonly commands;
45
+ constructor(commands?: CommandTimeouts);
43
46
  /**
44
47
  * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git
45
48
  * that errors, must read as "no worktrees" so the cap fails OPEN rather than blocking on data we
46
49
  * do not have.
50
+ *
51
+ * SYNCHRONOUS on purpose: the branch-creation guard reads it on the PreToolUse blocking path, whose
52
+ * `check()` contract is synchronous.
47
53
  */
48
54
  listWorktrees(repoRoot: string): Worktree[];
49
55
  linkedWorktrees(repoRoot: string): Worktree[];
@@ -102,12 +108,11 @@ export declare class WorktreeService {
102
108
  * since the fail-safe direction for deleting a directory is to leave it — but reporting the
103
109
  * second as the first tells an operator to go look for edits that are not there.
104
110
  */
105
- workInFlight(worktreePath: string): WorktreeWorkInFlight;
111
+ workInFlight(worktreePath: string): Promise<WorktreeWorkInFlight>;
106
112
  /**
107
113
  * Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank
108
114
  * line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree
109
115
  * still appears in the list, which is exactly why it can be reaped.
110
116
  */
111
117
  private parsePorcelain;
112
- private capture;
113
118
  }
package/src/worktrees.js CHANGED
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WorktreeService = exports.Worktree = exports.WorktreeWorkInFlight = void 0;
4
4
  const tslib_1 = require("tslib");
5
- const child_process_1 = require("child_process");
6
5
  const fs = tslib_1.__importStar(require("fs"));
7
6
  const path = tslib_1.__importStar(require("path"));
8
7
  const inversify_1 = require("inversify");
@@ -95,16 +94,26 @@ class Worktree {
95
94
  }
96
95
  exports.Worktree = Worktree;
97
96
  let WorktreeService = class WorktreeService {
97
+ commands;
98
+ // Defaulted so the non-DI call sites (the branch-creation guard) can `new WorktreeService()`, while
99
+ // inversify injects the container's singleton — whose process edges a feature test rebinds.
100
+ constructor(commands = new command_timeouts_1.CommandTimeouts()) {
101
+ this.commands = commands;
102
+ }
98
103
  /**
99
104
  * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git
100
105
  * that errors, must read as "no worktrees" so the cap fails OPEN rather than blocking on data we
101
106
  * do not have.
107
+ *
108
+ * SYNCHRONOUS on purpose: the branch-creation guard reads it on the PreToolUse blocking path, whose
109
+ * `check()` contract is synchronous.
102
110
  */
103
111
  listWorktrees(repoRoot) {
104
- const result = this.capture(repoRoot, ['worktree', 'list', '--porcelain']);
105
- if (!result.ok || result.out === '')
112
+ const result = this.commands.localGitNow(['worktree', 'list', '--porcelain'], repoRoot);
113
+ const out = result.stdout.trim();
114
+ if (!result.ok || out === '')
106
115
  return [];
107
- return this.parsePorcelain(result.out);
116
+ return this.parsePorcelain(out);
108
117
  }
109
118
  // The linked worktrees — everything except the primary clone. This is what the cap counts.
110
119
  linkedWorktrees(repoRoot) {
@@ -182,9 +191,9 @@ let WorktreeService = class WorktreeService {
182
191
  * since the fail-safe direction for deleting a directory is to leave it — but reporting the
183
192
  * second as the first tells an operator to go look for edits that are not there.
184
193
  */
185
- workInFlight(worktreePath) {
186
- const result = (0, child_process_1.spawnSync)('git', ['status', '--porcelain'], { cwd: worktreePath, encoding: 'utf8', timeout: command_timeouts_1.CommandTimeouts.LOCAL_GIT_MS });
187
- if (result.status !== 0 || typeof result.stdout !== 'string') {
194
+ async workInFlight(worktreePath) {
195
+ const result = await this.commands.localGit(['status', '--porcelain'], worktreePath);
196
+ if (!result.ok) {
188
197
  return new WorktreeWorkInFlight(true, '`git status` could not report on it, so it is not provably empty');
189
198
  }
190
199
  if (result.stdout.trim() !== '') {
@@ -246,16 +255,10 @@ let WorktreeService = class WorktreeService {
246
255
  flush();
247
256
  return trees;
248
257
  }
249
- // Run a git command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
250
- capture(repoRoot, args) {
251
- const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: command_timeouts_1.CommandTimeouts.LOCAL_GIT_MS });
252
- if (result.status !== 0 || typeof result.stdout !== 'string')
253
- return { ok: false, out: '' };
254
- return { ok: true, out: result.stdout.trim() };
255
- }
256
258
  };
257
259
  exports.WorktreeService = WorktreeService;
258
260
  exports.WorktreeService = WorktreeService = tslib_1.__decorate([
259
- (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
261
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
262
+ tslib_1.__metadata("design:paramtypes", [command_timeouts_1.CommandTimeouts])
260
263
  ], WorktreeService);
261
264
  //# sourceMappingURL=worktrees.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"worktrees.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/worktrees.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { toError } from '@webpieces/tooling-common/to-error';\nimport { CommandTimeouts } from './command-timeouts';\n\n/**\n * Reading the git worktree list.\n *\n * WHY this exists separately from merged-branches: a worktree is a SECOND budget. Every worktree holds\n * a branch, so if worktree-held branches also counted against the local-branch cap, five worktrees\n * would consume the entire branch budget and no branch could ever be created again. The two are\n * therefore counted apart — held branches against the worktree cap, parked branches against the branch\n * cap — and this service is what tells the two apart.\n *\n * Unlike the merged-PR lookup, everything here is LOCAL and cheap (one `git worktree list`), so it is\n * safe to call on the guard's blocking path.\n */\n\n// `git worktree list --porcelain` emits blank-line-separated records. The first record is always the\n// main worktree. Keys are space-separated; `detached`, `bare` and `locked` may appear bare (no value).\nconst WORKTREE_KEY = 'worktree ';\nconst BRANCH_KEY = 'branch ';\nconst HEAD_KEY = 'HEAD ';\nconst LOCKED_KEY = 'locked ';\nconst REFS_HEADS = 'refs/heads/';\n\n/**\n * Whether a worktree is being worked in, and the reason in the words an operator gets shown.\n *\n * Data-only (per CLAUDE.md, classes for data). A bare boolean was the first cut and it collapsed\n * \"there are uncommitted files here\" into \"git would not answer me\" — two different things to tell\n * somebody who is deciding whether a directory is safe to remove.\n */\nexport class WorktreeWorkInFlight {\n held: boolean;\n reason: string;\n\n constructor(held: boolean, reason: string) {\n this.held = held;\n this.reason = reason;\n }\n}\n\n// Data-only (per CLAUDE.md, classes for data).\nexport class Worktree {\n path: string;\n // Short branch name (refs/heads/ stripped). Empty when the worktree is detached or bare.\n branch: string;\n /**\n * The commit this worktree's HEAD is at, verbatim from the `HEAD <sha>` line every porcelain record\n * already carries. '' only for a bare worktree, which has no HEAD at all.\n *\n * Load-bearing, and the reason it stopped being dropped by the parser: a BRANCH NAME does not\n * identify a tree. A second clone, or a stale worktree, can hold the same name at a different\n * commit — which is the wrong-objects-under-the-right-name failure `wp-land-pr` already refused to\n * make from the tree it happened to be standing in. Pairing the name with this sha turns that veto\n * into a SELECTION: the tree to archive from and reap is the one whose HEAD is the exact commit\n * GitHub squashed, whichever directory the operator is standing in. See LandedTreeResolver.\n */\n head: string;\n // The primary clone — the one that owns .git. Never counted against the cap, never removable.\n isMain: boolean;\n // git already knows this worktree's directory is gone; `git worktree prune` will clear it.\n prunable: boolean;\n // Somebody ran `git worktree lock`. WHO is a question only `lockReason` can answer.\n locked: boolean;\n /**\n * The `--reason` text verbatim, '' when the lock carried none.\n *\n * Load-bearing, not decoration: the Claude Code agent harness locks every worktree it opens for a\n * subagent and writes a machine-readable reason naming the agent and its pid. Without this field\n * `locked` collapsed a live agent, a dead agent and a human into one verdict, and wp-cleanup\n * reported all three as \"locked by a human\". See agent-worktree-lock.ts.\n */\n lockReason: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n path: string,\n branch: string,\n // REQUIRED, with no default, for the same reason lockReason is: a defaulted '' would read as\n // \"this tree is not at any commit\", and every sha comparison built on it would silently decline.\n head: string,\n isMain: boolean,\n prunable: boolean,\n locked: boolean,\n // REQUIRED, with no default. A 5-arg construction would silently mean \"no reason recorded\",\n // which routes straight back to the bug this exists to fix — every agent lock unreadable and\n // its worktree spared forever. A missing argument must be a compile error, not a quiet ''.\n lockReason: string,\n ) {\n this.path = path;\n this.branch = branch;\n this.head = head;\n this.isMain = isMain;\n this.prunable = prunable;\n this.locked = locked;\n this.lockReason = lockReason;\n }\n}\n\n// Result of a captured git invocation: ok=false on spawn failure or non-zero exit.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeService {\n /**\n * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git\n * that errors, must read as \"no worktrees\" so the cap fails OPEN rather than blocking on data we\n * do not have.\n */\n listWorktrees(repoRoot: string): Worktree[] {\n const result = this.capture(repoRoot, ['worktree', 'list', '--porcelain']);\n if (!result.ok || result.out === '') return [];\n return this.parsePorcelain(result.out);\n }\n\n // The linked worktrees — everything except the primary clone. This is what the cap counts.\n linkedWorktrees(repoRoot: string): Worktree[] {\n return this.listWorktrees(repoRoot).filter((tree: Worktree): boolean => !tree.isMain);\n }\n\n /**\n * Branch names checked out in ANY worktree, including the primary clone's own HEAD.\n *\n * Two callers, one reason: git flatly refuses to delete a branch that is checked out somewhere.\n * A held branch must never be proposed for `git branch -D` (the delete would fail and take the\n * whole reap command down with it), and it must not be counted as a parked branch either.\n */\n heldBranches(repoRoot: string): Set<string> {\n const held = new Set<string>();\n for (const tree of this.listWorktrees(repoRoot)) {\n if (tree.branch !== '') held.add(tree.branch);\n }\n return held;\n }\n\n /**\n * Am I standing in a LINKED worktree (as opposed to the primary clone)?\n *\n * This is the question every recovery message needs, because the two trees take different\n * commands: `git checkout main` fatals inside a linked worktree (\"main is already checked out\n * at <primary>\"), and a dead linked worktree is reaped with `git worktree remove`, not\n * `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI\n * guess — which is exactly how an AI ends up running the fatal one.\n *\n * The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE\n * (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read\n * path, where reads vastly outnumber every other tool call, so the cost matters.\n *\n * It is a CHEAP FAST PATH over the authoritative answer, not a second authority. The authority is\n * `DotWebpieces.gitDirs` — `gitDir !== commonDir`, git's own canonical test, which also handles the\n * layouts a `.git` stat cannot see (`--separate-git-dir`, submodules). This is NOT calling it,\n * deliberately: that costs a `rev-parse` spawn per read. `worktree-identity.spec.ts` asserts the two\n * agree for the primary clone, an in-repo `.claude/worktrees/**` worktree and a sibling worktree\n * outside the repo — so if git ever changes the `.git` layout this fails there, not in the field.\n * Anything that needs to be RIGHT rather than cheap (tree identity, state paths) asks `gitDirs`.\n *\n * Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file\n * in a non-worktree checkout). False is the fail-open direction here: callers then print both\n * forms rather than confidently printing the wrong one.\n *\n * This answers \"am I in a linked worktree\", which is a question about MESSAGE WORDING and nothing\n * else. It is deliberately NOT a way to ask \"which worktree am I in\" so as to then act on that tree:\n * a `currentWorktree(root)` used to exist for exactly that and it is deleted, because pnpm hoists a\n * bin's cwd out of a nested `.claude/worktrees/**` tree and made the answer confidently wrong. Work\n * that has to NAME a tree resolves it from a commit — see pr-gate's LandedTreeResolver.\n */\n isLinkedWorktree(root: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return !fs.statSync(path.join(root, '.git')).isDirectory();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n /**\n * Is somebody working in this worktree — and if so, WHY do we say that?\n *\n * THE ONE CHECK THAT MAKES A ZERO-COMMIT WORKTREE UNREAPABLE. A ref with no commits of its own\n * loses nothing when it is deleted — that is a property of the REF. A DIRECTORY is different:\n * every worktree looks exactly like that husk from `git worktree add -b` until its first commit,\n * which is precisely the window an agent is working in. `git status --porcelain` is what tells\n * those two apart, and it is one cheap local spawn per candidate.\n *\n * It returns the REASON rather than a bare boolean because the two ways of being held are not the\n * same sentence to a human: \"it has uncommitted files\" is a fact about the tree, and \"git could\n * not tell me\" is a fact about our evidence. Both spare the worktree — unknown must fail SAFE,\n * since the fail-safe direction for deleting a directory is to leave it — but reporting the\n * second as the first tells an operator to go look for edits that are not there.\n */\n workInFlight(worktreePath: string): WorktreeWorkInFlight {\n const result = spawnSync('git', ['status', '--porcelain'], { cwd: worktreePath, encoding: 'utf8', timeout: CommandTimeouts.LOCAL_GIT_MS });\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return new WorktreeWorkInFlight(true,\n '`git status` could not report on it, so it is not provably empty');\n }\n if (result.stdout.trim() !== '') {\n return new WorktreeWorkInFlight(true, 'has uncommitted or untracked files');\n }\n return new WorktreeWorkInFlight(false, '');\n }\n\n /**\n * Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank\n * line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree\n * still appears in the list, which is exactly why it can be reaped.\n */\n private parsePorcelain(out: string): Worktree[] {\n const trees: Worktree[] = [];\n let path = '';\n let branch = '';\n let head = '';\n let prunable = false;\n let locked = false;\n let lockReason = '';\n\n const flush = (): void => {\n if (path === '') return;\n trees.push(new Worktree(path, branch, head, trees.length === 0, prunable, locked, lockReason));\n path = '';\n branch = '';\n head = '';\n prunable = false;\n locked = false;\n lockReason = '';\n };\n\n for (const raw of out.split('\\n')) {\n const line = raw.trim();\n if (line === '') {\n flush();\n } else if (line.startsWith(WORKTREE_KEY)) {\n // A new record begins — flush the previous one in case the blank line was missing.\n flush();\n path = line.slice(WORKTREE_KEY.length).trim();\n } else if (line.startsWith(HEAD_KEY)) {\n // Already on the wire — git emits it for every non-bare record, detached ones included.\n head = line.slice(HEAD_KEY.length).trim();\n } else if (line.startsWith(BRANCH_KEY)) {\n const ref = line.slice(BRANCH_KEY.length).trim();\n branch = ref.startsWith(REFS_HEADS) ? ref.slice(REFS_HEADS.length) : ref;\n } else if (line === 'prunable' || line.startsWith('prunable ')) {\n prunable = true;\n } else if (line === LOCKED_KEY.trim() || line.startsWith(LOCKED_KEY)) {\n locked = true;\n // `locked` alone is a lock with no --reason; `locked <text>` carries the reason verbatim.\n lockReason = line.slice(LOCKED_KEY.length).trim();\n }\n }\n flush();\n\n return trees;\n }\n\n // Run a git command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: CommandTimeouts.LOCAL_GIT_MS });\n if (result.status !== 0 || typeof result.stdout !== 'string') return { ok: false, out: '' };\n return { ok: true, out: result.stdout.trim() };\n }\n}\n"]}
1
+ {"version":3,"file":"worktrees.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/worktrees.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { toError } from '@webpieces/tooling-common/to-error';\nimport { CommandTimeouts } from './command-timeouts';\n\n/**\n * Reading the git worktree list.\n *\n * WHY this exists separately from merged-branches: a worktree is a SECOND budget. Every worktree holds\n * a branch, so if worktree-held branches also counted against the local-branch cap, five worktrees\n * would consume the entire branch budget and no branch could ever be created again. The two are\n * therefore counted apart — held branches against the worktree cap, parked branches against the branch\n * cap — and this service is what tells the two apart.\n *\n * Unlike the merged-PR lookup, everything here is LOCAL and cheap (one `git worktree list`), so it is\n * safe to call on the guard's blocking path.\n */\n\n// `git worktree list --porcelain` emits blank-line-separated records. The first record is always the\n// main worktree. Keys are space-separated; `detached`, `bare` and `locked` may appear bare (no value).\nconst WORKTREE_KEY = 'worktree ';\nconst BRANCH_KEY = 'branch ';\nconst HEAD_KEY = 'HEAD ';\nconst LOCKED_KEY = 'locked ';\nconst REFS_HEADS = 'refs/heads/';\n\n/**\n * Whether a worktree is being worked in, and the reason in the words an operator gets shown.\n *\n * Data-only (per CLAUDE.md, classes for data). A bare boolean was the first cut and it collapsed\n * \"there are uncommitted files here\" into \"git would not answer me\" — two different things to tell\n * somebody who is deciding whether a directory is safe to remove.\n */\nexport class WorktreeWorkInFlight {\n held: boolean;\n reason: string;\n\n constructor(held: boolean, reason: string) {\n this.held = held;\n this.reason = reason;\n }\n}\n\n// Data-only (per CLAUDE.md, classes for data).\nexport class Worktree {\n path: string;\n // Short branch name (refs/heads/ stripped). Empty when the worktree is detached or bare.\n branch: string;\n /**\n * The commit this worktree's HEAD is at, verbatim from the `HEAD <sha>` line every porcelain record\n * already carries. '' only for a bare worktree, which has no HEAD at all.\n *\n * Load-bearing, and the reason it stopped being dropped by the parser: a BRANCH NAME does not\n * identify a tree. A second clone, or a stale worktree, can hold the same name at a different\n * commit — which is the wrong-objects-under-the-right-name failure `wp-land-pr` already refused to\n * make from the tree it happened to be standing in. Pairing the name with this sha turns that veto\n * into a SELECTION: the tree to archive from and reap is the one whose HEAD is the exact commit\n * GitHub squashed, whichever directory the operator is standing in. See LandedTreeResolver.\n */\n head: string;\n // The primary clone — the one that owns .git. Never counted against the cap, never removable.\n isMain: boolean;\n // git already knows this worktree's directory is gone; `git worktree prune` will clear it.\n prunable: boolean;\n // Somebody ran `git worktree lock`. WHO is a question only `lockReason` can answer.\n locked: boolean;\n /**\n * The `--reason` text verbatim, '' when the lock carried none.\n *\n * Load-bearing, not decoration: the Claude Code agent harness locks every worktree it opens for a\n * subagent and writes a machine-readable reason naming the agent and its pid. Without this field\n * `locked` collapsed a live agent, a dead agent and a human into one verdict, and wp-cleanup\n * reported all three as \"locked by a human\". See agent-worktree-lock.ts.\n */\n lockReason: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n path: string,\n branch: string,\n // REQUIRED, with no default, for the same reason lockReason is: a defaulted '' would read as\n // \"this tree is not at any commit\", and every sha comparison built on it would silently decline.\n head: string,\n isMain: boolean,\n prunable: boolean,\n locked: boolean,\n // REQUIRED, with no default. A 5-arg construction would silently mean \"no reason recorded\",\n // which routes straight back to the bug this exists to fix — every agent lock unreadable and\n // its worktree spared forever. A missing argument must be a compile error, not a quiet ''.\n lockReason: string,\n ) {\n this.path = path;\n this.branch = branch;\n this.head = head;\n this.isMain = isMain;\n this.prunable = prunable;\n this.locked = locked;\n this.lockReason = lockReason;\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeService {\n // Defaulted so the non-DI call sites (the branch-creation guard) can `new WorktreeService()`, while\n // inversify injects the container's singleton — whose process edges a feature test rebinds.\n constructor(private readonly commands: CommandTimeouts = new CommandTimeouts()) {}\n\n /**\n * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git\n * that errors, must read as \"no worktrees\" so the cap fails OPEN rather than blocking on data we\n * do not have.\n *\n * SYNCHRONOUS on purpose: the branch-creation guard reads it on the PreToolUse blocking path, whose\n * `check()` contract is synchronous.\n */\n listWorktrees(repoRoot: string): Worktree[] {\n const result = this.commands.localGitNow(['worktree', 'list', '--porcelain'], repoRoot);\n const out = result.stdout.trim();\n if (!result.ok || out === '') return [];\n return this.parsePorcelain(out);\n }\n\n // The linked worktrees — everything except the primary clone. This is what the cap counts.\n linkedWorktrees(repoRoot: string): Worktree[] {\n return this.listWorktrees(repoRoot).filter((tree: Worktree): boolean => !tree.isMain);\n }\n\n /**\n * Branch names checked out in ANY worktree, including the primary clone's own HEAD.\n *\n * Two callers, one reason: git flatly refuses to delete a branch that is checked out somewhere.\n * A held branch must never be proposed for `git branch -D` (the delete would fail and take the\n * whole reap command down with it), and it must not be counted as a parked branch either.\n */\n heldBranches(repoRoot: string): Set<string> {\n const held = new Set<string>();\n for (const tree of this.listWorktrees(repoRoot)) {\n if (tree.branch !== '') held.add(tree.branch);\n }\n return held;\n }\n\n /**\n * Am I standing in a LINKED worktree (as opposed to the primary clone)?\n *\n * This is the question every recovery message needs, because the two trees take different\n * commands: `git checkout main` fatals inside a linked worktree (\"main is already checked out\n * at <primary>\"), and a dead linked worktree is reaped with `git worktree remove`, not\n * `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI\n * guess — which is exactly how an AI ends up running the fatal one.\n *\n * The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE\n * (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read\n * path, where reads vastly outnumber every other tool call, so the cost matters.\n *\n * It is a CHEAP FAST PATH over the authoritative answer, not a second authority. The authority is\n * `DotWebpieces.gitDirs` — `gitDir !== commonDir`, git's own canonical test, which also handles the\n * layouts a `.git` stat cannot see (`--separate-git-dir`, submodules). This is NOT calling it,\n * deliberately: that costs a `rev-parse` spawn per read. `worktree-identity.spec.ts` asserts the two\n * agree for the primary clone, an in-repo `.claude/worktrees/**` worktree and a sibling worktree\n * outside the repo — so if git ever changes the `.git` layout this fails there, not in the field.\n * Anything that needs to be RIGHT rather than cheap (tree identity, state paths) asks `gitDirs`.\n *\n * Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file\n * in a non-worktree checkout). False is the fail-open direction here: callers then print both\n * forms rather than confidently printing the wrong one.\n *\n * This answers \"am I in a linked worktree\", which is a question about MESSAGE WORDING and nothing\n * else. It is deliberately NOT a way to ask \"which worktree am I in\" so as to then act on that tree:\n * a `currentWorktree(root)` used to exist for exactly that and it is deleted, because pnpm hoists a\n * bin's cwd out of a nested `.claude/worktrees/**` tree and made the answer confidently wrong. Work\n * that has to NAME a tree resolves it from a commit — see pr-gate's LandedTreeResolver.\n */\n isLinkedWorktree(root: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return !fs.statSync(path.join(root, '.git')).isDirectory();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n /**\n * Is somebody working in this worktree — and if so, WHY do we say that?\n *\n * THE ONE CHECK THAT MAKES A ZERO-COMMIT WORKTREE UNREAPABLE. A ref with no commits of its own\n * loses nothing when it is deleted — that is a property of the REF. A DIRECTORY is different:\n * every worktree looks exactly like that husk from `git worktree add -b` until its first commit,\n * which is precisely the window an agent is working in. `git status --porcelain` is what tells\n * those two apart, and it is one cheap local spawn per candidate.\n *\n * It returns the REASON rather than a bare boolean because the two ways of being held are not the\n * same sentence to a human: \"it has uncommitted files\" is a fact about the tree, and \"git could\n * not tell me\" is a fact about our evidence. Both spare the worktree — unknown must fail SAFE,\n * since the fail-safe direction for deleting a directory is to leave it — but reporting the\n * second as the first tells an operator to go look for edits that are not there.\n */\n async workInFlight(worktreePath: string): Promise<WorktreeWorkInFlight> {\n const result = await this.commands.localGit(['status', '--porcelain'], worktreePath);\n if (!result.ok) {\n return new WorktreeWorkInFlight(true,\n '`git status` could not report on it, so it is not provably empty');\n }\n if (result.stdout.trim() !== '') {\n return new WorktreeWorkInFlight(true, 'has uncommitted or untracked files');\n }\n return new WorktreeWorkInFlight(false, '');\n }\n\n /**\n * Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank\n * line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree\n * still appears in the list, which is exactly why it can be reaped.\n */\n private parsePorcelain(out: string): Worktree[] {\n const trees: Worktree[] = [];\n let path = '';\n let branch = '';\n let head = '';\n let prunable = false;\n let locked = false;\n let lockReason = '';\n\n const flush = (): void => {\n if (path === '') return;\n trees.push(new Worktree(path, branch, head, trees.length === 0, prunable, locked, lockReason));\n path = '';\n branch = '';\n head = '';\n prunable = false;\n locked = false;\n lockReason = '';\n };\n\n for (const raw of out.split('\\n')) {\n const line = raw.trim();\n if (line === '') {\n flush();\n } else if (line.startsWith(WORKTREE_KEY)) {\n // A new record begins — flush the previous one in case the blank line was missing.\n flush();\n path = line.slice(WORKTREE_KEY.length).trim();\n } else if (line.startsWith(HEAD_KEY)) {\n // Already on the wire — git emits it for every non-bare record, detached ones included.\n head = line.slice(HEAD_KEY.length).trim();\n } else if (line.startsWith(BRANCH_KEY)) {\n const ref = line.slice(BRANCH_KEY.length).trim();\n branch = ref.startsWith(REFS_HEADS) ? ref.slice(REFS_HEADS.length) : ref;\n } else if (line === 'prunable' || line.startsWith('prunable ')) {\n prunable = true;\n } else if (line === LOCKED_KEY.trim() || line.startsWith(LOCKED_KEY)) {\n locked = true;\n // `locked` alone is a lock with no --reason; `locked <text>` carries the reason verbatim.\n lockReason = line.slice(LOCKED_KEY.length).trim();\n }\n }\n flush();\n\n return trees;\n }\n}\n"]}