Skip to content

Commit 1285dc2

Browse files
that-github-userunknownclaude
authored
Add thinktank clean command to manage worktree accumulation (#98)
- Lists and removes all thinktank worktrees (safely unlinks junctions first) - Prunes orphaned worktrees, deletes thinktank/* branches - --all flag to also delete .thinktank/ run history - 5 tests for clean functionality Generated by thinktank Opus (5 agents, 2 succeeded, Agent #5 recommended). Closes #56 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 12d13a0 commit 1285dc2

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { Command } from "commander";
44
import { apply } from "./commands/apply.js";
5+
import { clean } from "./commands/clean.js";
56
import { compare } from "./commands/compare.js";
67
import { list } from "./commands/list.js";
78
import { run } from "./commands/run.js";
@@ -98,6 +99,16 @@ program
9899
await list();
99100
});
100101

102+
program
103+
.command("clean")
104+
.description("Remove thinktank worktrees, branches, and optionally run history")
105+
.option("--all", "Also delete .thinktank/ run history")
106+
.action(async (opts) => {
107+
await clean({
108+
all: opts.all ?? false,
109+
});
110+
});
111+
101112
program
102113
.command("stats")
103114
.description("Show aggregate statistics across all thinktank runs")

src/commands/clean.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import { parseThinktankWorktrees } from "./clean.js";
4+
5+
describe("parseThinktankWorktrees", () => {
6+
it("extracts thinktank-agent paths from git worktree list output", () => {
7+
const output = [
8+
"/home/user/project abc1234 [main]",
9+
"/tmp/thinktank-agent-1-abcd1234 def5678 [thinktank/agent-1-abcd1234]",
10+
"/tmp/thinktank-agent-2-efgh5678 ghi9012 [thinktank/agent-2-efgh5678]",
11+
].join("\n");
12+
13+
const result = parseThinktankWorktrees(output);
14+
assert.deepEqual(result, [
15+
"/tmp/thinktank-agent-1-abcd1234",
16+
"/tmp/thinktank-agent-2-efgh5678",
17+
]);
18+
});
19+
20+
it("returns empty array when no thinktank worktrees exist", () => {
21+
const output = "/home/user/project abc1234 [main]\n";
22+
const result = parseThinktankWorktrees(output);
23+
assert.deepEqual(result, []);
24+
});
25+
26+
it("returns empty array for empty output", () => {
27+
assert.deepEqual(parseThinktankWorktrees(""), []);
28+
});
29+
30+
it("handles Windows-style paths", () => {
31+
const output = [
32+
"C:/Users/dev/project abc1234 [main]",
33+
"C:/Users/dev/AppData/Local/Temp/thinktank-agent-3-xyz def5678 [thinktank/agent-3-xyz]",
34+
].join("\n");
35+
36+
const result = parseThinktankWorktrees(output);
37+
assert.deepEqual(result, ["C:/Users/dev/AppData/Local/Temp/thinktank-agent-3-xyz"]);
38+
});
39+
40+
it("ignores paths that contain thinktank but not thinktank-agent", () => {
41+
const output = [
42+
"/home/user/thinktank abc1234 [main]",
43+
"/tmp/thinktank-agent-1-abcd def5678 [thinktank/agent-1-abcd]",
44+
].join("\n");
45+
46+
const result = parseThinktankWorktrees(output);
47+
assert.deepEqual(result, ["/tmp/thinktank-agent-1-abcd"]);
48+
});
49+
});

src/commands/clean.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { execFile } from "node:child_process";
2+
import { rm } from "node:fs/promises";
3+
import { promisify } from "node:util";
4+
import pc from "picocolors";
5+
import { cleanupBranches, getRepoRoot, removeWorktree } from "../utils/git.js";
6+
7+
const exec = promisify(execFile);
8+
9+
export interface CleanOptions {
10+
all?: boolean;
11+
}
12+
13+
/**
14+
* Parse `git worktree list` output and return paths of thinktank worktrees.
15+
*/
16+
export function parseThinktankWorktrees(worktreeOutput: string): string[] {
17+
const paths: string[] = [];
18+
for (const line of worktreeOutput.split("\n")) {
19+
// Each line: /path/to/worktree <hash> [branch]
20+
const worktreePath = line.split(/\s+/)[0];
21+
if (worktreePath && /thinktank-agent/.test(worktreePath)) {
22+
paths.push(worktreePath);
23+
}
24+
}
25+
return paths;
26+
}
27+
28+
export async function clean(opts: CleanOptions): Promise<void> {
29+
const repoRoot = await getRepoRoot();
30+
let removedCount = 0;
31+
32+
// Step 1: List and remove thinktank worktrees
33+
console.log();
34+
console.log(pc.bold(" Cleaning thinktank worktrees..."));
35+
36+
let worktrees: string[] = [];
37+
try {
38+
const { stdout } = await exec("git", ["worktree", "list"], { cwd: repoRoot });
39+
worktrees = parseThinktankWorktrees(stdout);
40+
} catch {
41+
console.log(pc.yellow(" Could not list worktrees."));
42+
}
43+
44+
if (worktrees.length === 0) {
45+
console.log(pc.dim(" No thinktank worktrees found."));
46+
} else {
47+
for (const wt of worktrees) {
48+
try {
49+
await removeWorktree(wt);
50+
console.log(` ${pc.green("✓")} Removed ${pc.dim(wt)}`);
51+
removedCount++;
52+
} catch {
53+
console.log(` ${pc.yellow("!")} Failed to remove ${pc.dim(wt)}`);
54+
}
55+
}
56+
}
57+
58+
// Step 2: Prune orphaned worktrees
59+
console.log(pc.bold(" Pruning orphaned worktrees..."));
60+
try {
61+
await exec("git", ["worktree", "prune"], { cwd: repoRoot });
62+
console.log(` ${pc.green("✓")} Pruned`);
63+
} catch {
64+
console.log(pc.yellow(" Could not prune worktrees."));
65+
}
66+
67+
// Step 3: Delete thinktank/* branches
68+
console.log(pc.bold(" Deleting thinktank/* branches..."));
69+
try {
70+
const { stdout } = await exec("git", ["branch", "--list", "thinktank/*"], { cwd: repoRoot });
71+
const branches = stdout
72+
.split("\n")
73+
.map((b) => b.trim())
74+
.filter(Boolean);
75+
if (branches.length === 0) {
76+
console.log(pc.dim(" No thinktank branches found."));
77+
} else {
78+
await cleanupBranches();
79+
console.log(
80+
` ${pc.green("✓")} Deleted ${branches.length} branch${branches.length === 1 ? "" : "es"}`,
81+
);
82+
}
83+
} catch {
84+
console.log(pc.dim(" No thinktank branches found."));
85+
}
86+
87+
// Step 4: Optionally delete .thinktank/ run history
88+
if (opts.all) {
89+
console.log(pc.bold(" Deleting .thinktank/ run history..."));
90+
try {
91+
await rm(".thinktank", { recursive: true, force: true });
92+
console.log(` ${pc.green("✓")} Deleted .thinktank/`);
93+
} catch {
94+
console.log(pc.dim(" No .thinktank/ directory found."));
95+
}
96+
}
97+
98+
// Summary
99+
console.log();
100+
console.log(
101+
pc.bold(" Done.") +
102+
(removedCount > 0
103+
? ` Removed ${removedCount} worktree${removedCount === 1 ? "" : "s"}.`
104+
: " Nothing to clean up."),
105+
);
106+
console.log();
107+
}

0 commit comments

Comments
 (0)