150 lines
4.8 KiB
TypeScript
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();
|