/** * CLI handler for `omp worktree` — list and clean up agent-managed worktrees. * * Layout under `~/.omp/wt/`: * * - **PR-checkout worktrees** (`tools/gh.ts`): a regular git worktree dir * containing a `.git` *file* that points back at * `/.git/worktrees//`. * - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a * `merged` subdir mounted/cloned by `natives.isoStart`. These are ephemeral * — `ensureIsolation` always `rm -rf`s the base before re-creating it, so * any leftover on disk is a leak from a crashed run. * * Legacy entries from before the encoding change keep working because git still * tracks them by branch name. This command exists to GC them on demand. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getWorktreesDir, isEnoent } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import * as git from "../utils/git"; type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray"; export interface WorktreeEntry { /** Absolute path to the worktree dir (or stray container) under `~/.omp/wt/`. */ path: string; /** Classification of what we found on disk. */ kind: WorktreeKind; /** Parent repo root, when this is a registered git worktree. */ parentRepo?: string; /** Branch name extracted from the parent's tracking file, when available. */ branch?: string; /** When set, the entry is unhealthy and `omp worktree clear` will remove it. */ orphanReason?: string; } export interface ListWorktreesOptions { json: boolean; } export interface ClearWorktreesOptions { /** Remove every entry, including live PR-checkout worktrees. */ all: boolean; /** Print what would be removed without touching the filesystem. */ dryRun: boolean; json: boolean; } export async function listWorktrees(options: ListWorktreesOptions): Promise { const entries = await scanWorktrees(); if (options.json) { console.log(JSON.stringify(entries, null, 2)); return; } if (entries.length === 0) { console.log(chalk.dim(`No agent-managed worktrees found under ${getWorktreesDir()}.`)); return; } let live = 0; let orphaned = 0; for (const entry of entries) { const tag = entry.orphanReason ? chalk.yellow("orphaned") : chalk.green("live "); const detail = formatEntryDetail(entry); console.log(`${tag} ${entry.path}`); if (detail) console.log(` ${chalk.dim(detail)}`); if (entry.orphanReason) orphaned += 1; else live += 1; } console.log(chalk.dim(`\n${live} live · ${orphaned} orphaned · ${entries.length} total`)); } export async function clearWorktrees(options: ClearWorktreesOptions): Promise { const entries = await scanWorktrees(); const targets = options.all ? entries : entries.filter(entry => entry.orphanReason !== undefined); if (targets.length === 0) { if (options.json) { console.log(JSON.stringify({ removed: 0, kept: entries.length })); } else { console.log(chalk.dim(options.all ? "No worktrees to remove." : "No orphaned worktrees to remove.")); } return; } if (options.dryRun) { if (options.json) { console.log(JSON.stringify({ wouldRemove: targets.map(t => t.path) }, null, 2)); } else { for (const target of targets) { console.log(`${chalk.yellow("would remove")} ${target.path}`); } console.log(chalk.dim(`\n${targets.length} dir${targets.length === 1 ? "" : "s"} would be removed.`)); } return; } const results: { path: string; ok: boolean; error?: string }[] = []; const parentsToPrune = new Set(); for (const target of targets) { try { if (target.kind === "pr-checkout" && target.parentRepo && !target.orphanReason) { // Live worktree: ask git to remove it cleanly. If git refuses (locked, // dirty, etc.), fall back to fs.rm and rely on `worktree prune` to // clean the bookkeeping on the parent side. const removed = await git.worktree.tryRemove(target.parentRepo, target.path, { force: true }); if (!removed) { await fs.rm(target.path, { recursive: true, force: true }); parentsToPrune.add(target.parentRepo); } } else { await fs.rm(target.path, { recursive: true, force: true }); if (target.parentRepo) parentsToPrune.add(target.parentRepo); } results.push({ path: target.path, ok: true }); } catch (err) { results.push({ path: target.path, ok: false, error: err instanceof Error ? err.message : String(err) }); } } // Best-effort: drop stale entries from each affected parent's `.git/worktrees/`. for (const parent of parentsToPrune) { try { await git.worktree.prune(parent); } catch { /* parent repo may already be gone or pruned — ignore */ } } const succeeded = results.filter(r => r.ok).length; const failed = results.length - succeeded; if (options.json) { console.log(JSON.stringify({ removed: succeeded, failed, results }, null, 2)); if (failed > 0) process.exitCode = 1; return; } for (const result of results) { if (result.ok) { console.log(`${chalk.green("removed")} ${result.path}`); } else { console.log(`${chalk.red("failed ")} ${result.path}`); if (result.error) console.log(` ${chalk.dim(result.error)}`); } } console.log(chalk.dim(`\n${succeeded} removed${failed > 0 ? ` · ${chalk.red(`${failed} failed`)}` : ""}`)); if (failed > 0) process.exitCode = 1; } // ─────────────────────────────────────────────────────────────────────────── // Scanner // ─────────────────────────────────────────────────────────────────────────── async function scanWorktrees(): Promise { const root = getWorktreesDir(); let topLevel: string[]; try { topLevel = await fs.readdir(root); } catch (err) { if (isEnoent(err)) return []; throw err; } const entries: WorktreeEntry[] = []; for (const name of topLevel) { const dir = path.join(root, name); const stat = await fs.stat(dir).catch(() => null); if (!stat?.isDirectory()) continue; const direct = await classifyDir(dir); if (direct) { entries.push(direct); continue; } // Legacy nesting: ~/.omp/wt// let children: string[]; try { children = await fs.readdir(dir); } catch { continue; } let nested = 0; for (const child of children) { const childDir = path.join(dir, child); const childStat = await fs.stat(childDir).catch(() => null); if (!childStat?.isDirectory()) continue; const childClassified = await classifyDir(childDir); if (childClassified) { entries.push(childClassified); nested += 1; } } if (nested === 0) { entries.push({ path: dir, kind: children.length === 0 ? "empty" : "stray", orphanReason: children.length === 0 ? "empty directory" : "no recognizable worktree contents", }); } } return entries; } async function classifyDir(dir: string): Promise { const gitEntry = path.join(dir, ".git"); const gitStat = await fs.stat(gitEntry).catch(() => null); if (gitStat?.isFile()) { return classifyPrCheckout(dir, gitEntry); } const mergedStat = await fs.stat(path.join(dir, "merged")).catch(() => null); if (mergedStat?.isDirectory()) { return { path: dir, kind: "task-isolation", orphanReason: "task-isolation leftover (no live task owns it)", }; } return null; } async function classifyPrCheckout(dir: string, gitEntry: string): Promise { let contents: string; try { contents = await fs.readFile(gitEntry, "utf8"); } catch (err) { return { path: dir, kind: "pr-checkout", orphanReason: `cannot read .git file: ${err instanceof Error ? err.message : String(err)}`, }; } const match = /^gitdir:\s*(.+?)\s*$/m.exec(contents); const parentGitDir = match?.[1]; if (!parentGitDir) { return { path: dir, kind: "pr-checkout", orphanReason: "malformed .git file (no gitdir line)" }; } // parentGitDir is `/.git/worktrees/`; back out the repo root. const parentRepo = path.dirname(path.dirname(path.dirname(parentGitDir))); const branch = await readWorktreeBranch(path.join(parentGitDir, "HEAD")); const parentDirStat = await fs.stat(parentGitDir).catch(() => null); if (!parentDirStat?.isDirectory()) { return { path: dir, kind: "pr-checkout", parentRepo, branch, orphanReason: "parent repo no longer tracks this worktree", }; } const parentRepoStat = await fs.stat(parentRepo).catch(() => null); if (!parentRepoStat?.isDirectory()) { return { path: dir, kind: "pr-checkout", parentRepo, branch, orphanReason: "parent repo missing", }; } return { path: dir, kind: "pr-checkout", parentRepo, branch }; } async function readWorktreeBranch(headFile: string): Promise { try { const head = (await fs.readFile(headFile, "utf8")).trim(); const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head); return refMatch?.[1]; } catch { return undefined; } } function formatEntryDetail(entry: WorktreeEntry): string { const parts: string[] = []; if (entry.kind === "pr-checkout") { const repo = entry.parentRepo ? path.basename(entry.parentRepo) : "unknown repo"; const branch = entry.branch ?? "unknown branch"; parts.push(`${repo} · ${branch}`); } else if (entry.kind === "task-isolation") { parts.push("task-isolation sandbox"); } else if (entry.kind === "empty") { parts.push("legacy project shell"); } else { parts.push("unrecognized contents"); } if (entry.orphanReason) parts.push(entry.orphanReason); return parts.join(" — "); }