Initial Commit
This commit is contained in:
149
scripts/worktree-prune.ts
Normal file
149
scripts/worktree-prune.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user