#!/usr/bin/env bun /** * Audit and prune stale worktrees under `.claude/worktrees/`. * * Default mode (no args): reports per-worktree status (branch, HEAD SHA, * dirty/clean, reachable-from-a-branch). Exit 0 always. * * `--remove`: removes every worktree whose HEAD commit IS reachable from * a branch ref in the main repo (so the work survives) AND whose tree is * clean (so no uncommitted work is lost). Skips and reports any worktree * that doesn't meet both criteria. * * `--force`: with `--remove`, also removes dirty worktrees. Use with care * — uncommitted changes inside the worktree will be lost. */ import { execFileSync } from "node:child_process"; interface WorktreeRow { readonly path: string; readonly branch: string; readonly sha: string; readonly dirty: boolean; readonly reachable: boolean; readonly locked: boolean; } function git(args: readonly string[], cwd?: string): string { const opts = cwd ? { cwd, encoding: "utf8" as const } : { encoding: "utf8" as const }; return execFileSync("git", args, opts).trim(); } function listWorktrees(): readonly WorktreeRow[] { const out = git(["worktree", "list", "--porcelain"]); const blocks = out.split("\n\n").filter((b) => b.trim() !== ""); const root = git(["rev-parse", "--show-toplevel"]); const rows: WorktreeRow[] = []; for (const block of blocks) { const lines = block.split("\n"); const path = lines .find((l) => l.startsWith("worktree "))! .slice("worktree ".length); if (path === root) continue; if (!path.includes(".claude/worktrees/")) continue; const sha = lines .find((l) => l.startsWith("HEAD "))! .slice("HEAD ".length); const branchLine = lines.find((l) => l.startsWith("branch ")); const branch = branchLine ? branchLine.slice("branch ".length).replace(/^refs\/heads\//, "") : "(detached)"; const status = git(["status", "--porcelain"], path); const dirty = status.length > 0; const containing = git([ "branch", "--contains", sha, "--format=%(refname:short)", ]) .split("\n") .filter((b) => b.trim() && b !== branch); const reachable = containing.length > 0 || branch.startsWith("feat/"); const locked = lines.some( (l) => l === "locked" || l.startsWith("locked ") ); rows.push({ path, branch, sha: sha.slice(0, 9), dirty, reachable, locked, }); } return rows; } function printTable(rows: readonly WorktreeRow[]): void { if (rows.length === 0) { console.log("No worktrees under .claude/worktrees/."); return; } console.log( `${"branch".padEnd(40)} ${"sha".padEnd(10)} ${"dirty".padEnd(7)} reachable` ); console.log("-".repeat(80)); for (const r of rows) { console.log( `${r.branch.padEnd(40)} ${r.sha.padEnd(10)} ${(r.dirty ? "dirty" : "clean").padEnd(7)} ${r.reachable ? "yes" : "no"}` ); } } function prune(rows: readonly WorktreeRow[], force: boolean): void { let removed = 0; let skipped = 0; for (const r of rows) { if (r.locked && !force) { console.log( `SKIP ${r.branch}: worktree is locked (active agent likely running). Re-run with --force to override — risks destroying in-progress work that hasn't committed yet.` ); skipped++; continue; } if (!r.reachable) { console.log( `SKIP ${r.branch}: HEAD ${r.sha} not reachable from any branch — would lose work` ); skipped++; continue; } if (r.dirty && !force) { console.log( `SKIP ${r.branch}: uncommitted changes (re-run with --force to discard them)` ); skipped++; continue; } try { git(["worktree", "unlock", r.path]); } catch { // already unlocked } git(["worktree", "remove", "--force", r.path]); console.log(`REMOVED ${r.branch} (${r.sha})`); removed++; } console.log(`\nRemoved ${removed}, skipped ${skipped}.`); } function main(): void { const args = process.argv.slice(2); const doRemove = args.includes("--remove"); const force = args.includes("--force"); const rows = listWorktrees(); printTable(rows); if (doRemove) { console.log(""); prune(rows, force); } else if (rows.length > 0) { console.log( `\n(${rows.length} stale worktree${rows.length === 1 ? "" : "s"}; re-run with --remove to prune.)` ); } } main();