Files
suaveplan/scripts/worktree-prune.ts
2026-05-27 23:47:53 +01:00

150 lines
4.8 KiB
TypeScript

#!/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();