import { existsSync, readdirSync, rmdirSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { git } from "../git"; import { contentContainedIn, landedBaseRef, unlandedContentReason } from "./content-landed"; import { worktreePathRedirected } from "./names"; import { protectedTipRef } from "./protected-tip"; import { bestEffortUnpublishReview } from "./review-ref"; export async function pruneWorktrees(input: { repoRoot?: string }): Promise<{ repoRoot: string; pruned: boolean; output?: string; error?: string }> { const repo = resolve(input.repoRoot ?? "."); const result = await git(["worktree", "prune"], repo); if (!result.ok) return { repoRoot: repo, pruned: false, error: result.stderr || "git worktree prune failed" }; return { repoRoot: repo, pruned: true, output: result.stdout.trim() || undefined }; } // Repo that actually owns `worktreePath`'s git admin. A CHAINED workspace's recorded // repoRoot points at the PARENT worktree (maybe deleted), not the real checkout (#278/#307); // every linked worktree shares the main `.git` (--git-common-dir), so derive the owner from // the worktree itself, falling back to `fallback` only when it can't be interrogated. export async function owningRepoRoot(worktreePath: string, fallback: string): Promise { const res = await git(["rev-parse", "--git-common-dir"], worktreePath); if (!res.ok || !res.stdout) return fallback; let commonDir = res.stdout; if (!isAbsolute(commonDir)) commonDir = resolve(worktreePath, commonDir); const root = basename(commonDir) === ".git" ? dirname(commonDir) : commonDir; // .git's parent = repo return existsSync(root) ? root : fallback; } export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; baseRef?: string; baseSha?: string; deleteBranch?: boolean; workspacesRoot?: string; force?: boolean; reason?: string }): Promise<{ workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; branchPreservedReason?: string; containerRemoved?: boolean; dirtySnapshotRef?: string }> { if (!workspace.worktreePath) throw new Error("worktreePath required"); const path = resolve(workspace.worktreePath); // #1502 — never run `git worktree remove --force` through a final-component symlink: it would delete // whatever the link points at (e.g. a sibling victim's checkout). `resolve()` is lexical, so guard // the on-disk path here — the host is where the filesystem actually lives. if (worktreePathRedirected(path)) throw new Error(`worktree path is a symlink or unresolvable; refusing to clean up (#1502): ${path}`); const recordedRepo = workspace.repoRoot ? resolve(workspace.repoRoot) : path; // Remove via the REAL owning repo, not the recorded (chained/deleted) repoRoot which // would silently no-op and leak the dir while the relay marks it `cleaned` (#307). const repo = await owningRepoRoot(path, recordedRepo); let dirtySnapshotRef: string | undefined; if (existsSync(path)) { const safety = await worktreeSafeToRemove(path, workspace.baseRef, workspace.baseSha); const explicitForce = workspace.force === true && Boolean(workspace.reason?.trim()); if (!safety.safe && !explicitForce) throw new Error(`unsafe workspace cleanup refused: ${safety.reason}`); if (!safety.safe) dirtySnapshotRef = await snapshotDirtyWorktree(path, workspace.id); } const result = await git(["worktree", "remove", "--force", path], repo); // Trust the filesystem, not git's exit code (a remove against the wrong repo "succeeds" // touching nothing). If the dir survives, surface failure so the relay leaves the row // un-cleaned instead of recording a phantom removal (#307). if (existsSync(path)) { await git(["worktree", "prune"], repo); throw new Error(result.stderr || `worktree ${path} still present after \`git worktree remove\` (repo ${repo})`); } // Once the worktree is gone the agent/... branch is litter — delete it so // branches don't accumulate. First prove it has no unlanded commits, because // deleting the branch ref is what can make committed-but-unlanded work // unreachable after a crashed worker (#614). let branchDeleted = false; let branchPreservedReason: string | undefined; if (workspace.branch && workspace.deleteBranch !== false) { const safety = await branchSafeToDelete(repo, workspace.branch, workspace.baseRef, workspace.baseSha, workspace.id); if (safety.safe) { branchDeleted = (await git(["branch", "-D", workspace.branch], repo)).ok; if (!branchDeleted) branchPreservedReason = "branch delete failed"; } else { branchPreservedReason = safety.reason; } } const containerRemoved = workspace.workspacesRoot ? removeEmptyContainer(dirname(path), resolve(workspace.workspacesRoot)) : false; // #1452 — a torn-down workspace's review ref must not leak. Best-effort + unconditional (a ref // may survive from a prior ON era even if the feature is now OFF); a no-op when nothing was // ever published. Never fails cleanup. This single call covers the reap paths that reach // cleanupWorkspace: the workspace.cleanup command handler AND exit-time reconcileWorkspace. await bestEffortUnpublishReview({ repoRoot: repo, branch: workspace.branch, workspaceId: workspace.id }); return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved, ...(dirtySnapshotRef ? { dirtySnapshotRef } : {}) }; } async function worktreeSafeToRemove(worktreePath: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> { const status = await git(["status", "--porcelain"], worktreePath); if (!status.ok) return { safe: false, reason: status.stderr || "git status unavailable" }; const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0; if (dirtyCount > 0) return { safe: false, reason: `${dirtyCount} uncommitted change(s)` }; const base = await resolveCleanupBase(worktreePath, baseRef, baseSha); if (!base) return { safe: false, reason: "base ref unavailable" }; const counts = await git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath); if (!counts.ok || !counts.stdout) return { safe: false, reason: counts.stderr || "ahead count unavailable" }; const ahead = Number(counts.stdout.split(/\s+/)[1]); if (!Number.isFinite(ahead)) return { safe: false, reason: "ahead count unavailable" }; if (ahead === 0) return { safe: true }; return landedEnoughToDiscard(worktreePath, base, "HEAD"); } // #1634 — every "may this be discarded?" question in this file is the SAME content question, asked // through the one shared primitive so the cleanup path and the protected-tip path cannot drift on // what "already landed" means. Direction of the fail-safe here: `unknown` is NOT safe. This file's // callers delete worktrees, branches, and rescue refs, so an unprovable answer must preserve them — // the mirror image of protected-tip.ts, where an unprovable answer preserves the HEAD instead. async function landedEnoughToDiscard(cwd: string, base: string, target: string): Promise<{ safe: boolean; reason?: string }> { const containment = await contentContainedIn(cwd, target, await landedBaseRef(cwd, base)); return containment.verdict === "contained" ? { safe: true } : { safe: false, reason: unlandedContentReason(containment) }; } async function snapshotDirtyWorktree(worktreePath: string, workspaceId: string | undefined): Promise { const snapshot = await git(["stash", "create", `agent-relay cleanup snapshot ${workspaceId ?? "workspace"}`], worktreePath); const commit = snapshot.ok ? snapshot.stdout.trim() : ""; if (!commit) return undefined; const safe = (workspaceId ?? basename(worktreePath)).replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^\/+|\/+$/g, "").slice(0, 180) || "workspace"; const ref = `refs/agent-relay/workspace-tips/${safe}-dirty-${Date.now()}`; const updated = await git(["update-ref", ref, commit], worktreePath); return updated.ok ? ref : undefined; } export async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string, workspaceId?: string): Promise<{ safe: boolean; reason?: string }> { const branchRef = await resolveCommit(repo, branch) ? branch : await resolveCommit(repo, `refs/heads/${branch}`) ? `refs/heads/${branch}` : undefined; const rescueRef = protectedTipRef(workspaceId); const rescueTip = rescueRef ? await resolveCommitSha(repo, rescueRef) : undefined; if (!branchRef && !rescueTip) return { safe: true }; const base = await resolveCleanupBase(repo, baseRef, baseSha); if (!base) return { safe: false, reason: "base ref unavailable" }; const rescue = await rescueTipSafety(repo, base, rescueRef, rescueTip); if (!rescue.safe) return rescue; if (!branchRef) return { safe: true }; const counts = await git(["rev-list", "--left-right", "--count", `${base}...${branchRef}`], repo); if (!counts.ok || !counts.stdout) return { safe: false, reason: counts.stderr || "ahead count unavailable" }; const ahead = Number(counts.stdout.split(/\s+/)[1]); if (!Number.isFinite(ahead)) return { safe: false, reason: "ahead count unavailable" }; if (ahead === 0) return { safe: true }; return landedEnoughToDiscard(repo, base, branchRef); } export async function deleteBranchIfSafe(repo: string, branch: string, baseRef?: string, baseSha?: string, signal?: AbortSignal, workspaceId?: string): Promise<{ branchDeleted: boolean; branchPreservedReason?: string }> { if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted"); const safety = await branchSafeToDelete(repo, branch, baseRef, baseSha, workspaceId); if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted"); if (!safety.safe) return { branchDeleted: false, ...(safety.reason ? { branchPreservedReason: safety.reason } : {}) }; const branchDeleted = (await git(["branch", "-D", branch], repo, { signal })).ok; // #1452 — the review ref shares the branch's lifecycle: clean it wherever the branch dies // (land-and-delete / recycle via plain-git + merge). Best-effort, never blocks the land. if (branchDeleted) await bestEffortUnpublishReview({ repoRoot: repo, branch, workspaceId, signal }); return branchDeleted ? { branchDeleted } : { branchDeleted, branchPreservedReason: "branch delete failed" }; } async function resolveCleanupBase(repo: string, baseRef?: string, baseSha?: string): Promise { for (const candidate of [baseRef, baseSha, "main", "master"]) { if (candidate && await resolveCommit(repo, candidate)) return candidate; } return undefined; } async function resolveCommit(repo: string, ref: string): Promise { return (await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], repo)).ok; } async function resolveCommitSha(repo: string, ref: string): Promise { const result = await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], repo); return result.ok && result.stdout ? result.stdout.trim() : undefined; } async function rescueTipSafety(repo: string, base: string, ref: string | undefined, tip: string | undefined): Promise<{ safe: boolean; reason?: string }> { if (!ref || !tip) return { safe: true }; const safety = await landedEnoughToDiscard(repo, base, tip); return safety.safe ? safety : { safe: false, reason: `recoverable tip ${tip.slice(0, 12)} at ${ref} has ${safety.reason}` }; } export function sweepEmptyWorkspaceContainers(wsRoot: string): string[] { const root = resolve(wsRoot); if (!existsSync(root)) return []; const removed: string[] = []; for (const entry of readdirSync(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const dir = join(root, entry.name); if (readdirSync(dir).length === 0) { rmdirSync(dir); removed.push(dir); } } return removed; } function removeEmptyContainer(container: string, wsRoot: string): boolean { try { if (!existsSync(container)) return false; if (readdirSync(container).length !== 0) return false; if (!isDirectChildOf(container, wsRoot)) return false; rmdirSync(container); return true; } catch { return false; } } function isDirectChildOf(child: string, parent: string): boolean { const rel = relative(resolve(parent), resolve(child)); return !!rel && !rel.includes("/") && !rel.startsWith("..") && !isAbsolute(rel); }