Skip to content

Commit eda234a

Browse files
authored
feat: add webmux prune command (#118)
1 parent 5308220 commit eda234a

8 files changed

Lines changed: 245 additions & 9 deletions

File tree

backend/src/__tests__/lifecycle-service.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,36 @@ describe("LifecycleService", () => {
762762
expect(run(["git", "branch", "--list", "feature-ahead"], repoRoot)).toBe("");
763763
});
764764

765+
it("prunes all project worktrees", async () => {
766+
const repoRoot = await initRepo();
767+
const runtime = new ProjectRuntime();
768+
const tmux = new FakeTmuxGateway();
769+
const docker = new FakeDockerGateway();
770+
const hooks = new FakeHookRunner();
771+
const lifecycle = makeLifecycleService(repoRoot, tmux, runtime, docker, hooks);
772+
773+
await lifecycle.createWorktree({ branch: "feature-prune-host" });
774+
await lifecycle.createWorktree({ branch: "feature-prune-docker", profile: "sandbox" });
775+
await lifecycle.closeWorktree("feature-prune-host");
776+
777+
const result = await lifecycle.pruneWorktrees();
778+
779+
expect(result.removedBranches).toEqual([
780+
"feature-prune-docker",
781+
"feature-prune-host",
782+
]);
783+
expect(hooks.calls.filter((call) => call.name === "preRemove").map((call) => call.cwd).sort()).toEqual([
784+
join(repoRoot, "__worktrees", "feature-prune-docker"),
785+
join(repoRoot, "__worktrees", "feature-prune-host"),
786+
]);
787+
expect(docker.removed).toEqual(["feature-prune-docker"]);
788+
expect(new BunGitGateway().listWorktrees(repoRoot).filter((entry) => entry.path !== repoRoot)).toEqual([]);
789+
expect(run(["git", "branch", "--list", "feature-prune-host"], repoRoot)).toBe("");
790+
expect(run(["git", "branch", "--list", "feature-prune-docker"], repoRoot)).toBe("");
791+
expect(tmux.listWindows()).toEqual([]);
792+
expect(runtime.listWorktrees()).toEqual([]);
793+
});
794+
765795
it("removes the sandbox container before deleting a docker worktree", async () => {
766796
const repoRoot = await initRepo();
767797
const runtime = new ProjectRuntime();

backend/src/services/lifecycle-service.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export interface CreateLifecycleWorktreeInput {
8686
envOverrides?: Record<string, string>;
8787
}
8888

89+
export interface PruneWorktreesResult {
90+
removedBranches: string[];
91+
}
92+
8993
export class LifecycleError extends Error {
9094
constructor(
9195
message: string,
@@ -272,6 +276,23 @@ export class LifecycleService {
272276
}
273277
}
274278

279+
async pruneWorktrees(): Promise<PruneWorktreesResult> {
280+
try {
281+
const resolvedWorktrees = await this.resolveAllWorktrees();
282+
const removedBranches: string[] = [];
283+
284+
for (const resolved of resolvedWorktrees) {
285+
const branch = resolved.entry.branch ?? resolved.entry.path;
286+
await this.removeResolvedWorktree(resolved);
287+
removedBranches.push(branch);
288+
}
289+
290+
return { removedBranches };
291+
} catch (error) {
292+
throw this.wrapOperationError(error);
293+
}
294+
}
295+
275296
async mergeWorktree(branch: string): Promise<void> {
276297
try {
277298
const resolved = await this.resolveExistingWorktree(branch);
@@ -402,6 +423,18 @@ export class LifecycleService {
402423
return { entry, gitDir, meta };
403424
}
404425

426+
private async resolveAllWorktrees(): Promise<ResolvedLifecycleWorktree[]> {
427+
const entries = this.listProjectWorktrees().sort((left, right) =>
428+
(left.branch ?? left.path).localeCompare(right.branch ?? right.path)
429+
);
430+
431+
return await Promise.all(entries.map(async (entry) => {
432+
const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
433+
const meta = await readWorktreeMeta(gitDir);
434+
return { entry, gitDir, meta };
435+
}));
436+
}
437+
405438
private async initializeUnmanagedWorktree(
406439
resolved: ResolvedLifecycleWorktree,
407440
): Promise<InitializeManagedWorktreeResult> {

bin/src/completions.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ describe("runCompletionCommand", () => {
119119
const output = spy.mock.calls[0]?.[0];
120120
expect(output).toContain("#compdef webmux");
121121
expect(output).toContain("compdef _webmux webmux");
122+
expect(output).toContain("prune:Remove all worktrees in the current project");
122123
expect(output).not.toContain('_webmux "$@"');
123124
spy.mockRestore();
124125
});
@@ -128,6 +129,7 @@ describe("runCompletionCommand", () => {
128129
const code = runCompletionCommand(["bash"]);
129130
expect(code).toBe(0);
130131
expect(spy).toHaveBeenCalledWith(expect.stringContaining("complete -F _webmux webmux"));
132+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("prune"));
131133
spy.mockRestore();
132134
});
133135
});

bin/src/completions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ _webmux() {
127127
'close:Close a worktree session'
128128
'remove:Remove a worktree'
129129
'merge:Merge a worktree into main'
130+
'prune:Remove all worktrees in the current project'
130131
'completion:Generate shell completion script'
131132
)
132133
@@ -176,7 +177,7 @@ const BASH_SCRIPT = `_webmux() {
176177
prev="\${COMP_WORDS[COMP_CWORD-1]}"
177178
178179
if [[ \${COMP_CWORD} -eq 1 ]]; then
179-
COMPREPLY=($(compgen -W "serve init service update add list open close remove merge completion" -- "\${cur}"))
180+
COMPREPLY=($(compgen -W "serve init service update add list open close remove merge prune completion" -- "\${cur}"))
180181
return
181182
fi
182183

bin/src/webmux.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ describe("webmux entrypoint", () => {
6565
});
6666
});
6767

68+
it("parses prune as a worktree command", () => {
69+
delete process.env.PORT;
70+
71+
expect(parseRootArgs(["prune"])).toEqual({
72+
port: 5111,
73+
debug: false,
74+
command: "prune",
75+
commandArgs: [],
76+
});
77+
});
78+
6879
it("runs worktree commands from a project subdirectory", async () => {
6980
const repoRoot = await mkdtemp(join(tmpdir(), "webmux-cli-"));
7081
tempDirs.push(repoRoot);

bin/src/webmux.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Usage:
2525
webmux close Close a worktree session without removing it
2626
webmux remove Remove a worktree
2727
webmux merge Merge a worktree into the main branch and remove it
28+
webmux prune Remove all worktrees in the current project
2829
webmux completion Generate shell completion script (bash, zsh)
2930
3031
Options:
@@ -38,7 +39,7 @@ Environment:
3839
`);
3940
}
4041

41-
type RootCommand = "serve" | "init" | "service" | "update" | "add" | "list" | "open" | "close" | "remove" | "merge" | "completion" | null;
42+
type RootCommand = "serve" | "init" | "service" | "update" | "add" | "list" | "open" | "close" | "remove" | "merge" | "prune" | "completion" | null;
4243

4344
interface ParsedRootArgs {
4445
port: number;
@@ -58,6 +59,7 @@ function isRootCommand(value: string): value is NonNullable<RootCommand> {
5859
|| value === "close"
5960
|| value === "remove"
6061
|| value === "merge"
62+
|| value === "prune"
6163
|| value === "completion";
6264
}
6365

@@ -121,13 +123,14 @@ export function parseRootArgs(args: string[]): ParsedRootArgs {
121123
};
122124
}
123125

124-
function isWorktreeCommand(command: RootCommand): command is "add" | "list" | "open" | "close" | "remove" | "merge" {
126+
function isWorktreeCommand(command: RootCommand): command is "add" | "list" | "open" | "close" | "remove" | "merge" | "prune" {
125127
return command === "add"
126128
|| command === "list"
127129
|| command === "open"
128130
|| command === "close"
129131
|| command === "remove"
130-
|| command === "merge";
132+
|| command === "merge"
133+
|| command === "prune";
131134
}
132135

133136
// ── Load env files from CWD (.env.local overrides .env) ─────────────────────

bin/src/worktree-commands.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ function stubLifecycleService(calls: Array<{ method: string; value: unknown }>)
2222
async mergeWorktree(branch: string): Promise<void> {
2323
calls.push({ method: "mergeWorktree", value: branch });
2424
},
25+
async pruneWorktrees(): Promise<{ removedBranches: string[] }> {
26+
calls.push({ method: "pruneWorktrees", value: null });
27+
return { removedBranches: ["feature/search", "feature/api"] };
28+
},
2529
};
2630
}
2731

@@ -183,6 +187,66 @@ describe("runWorktreeCommand", () => {
183187
expect(stdout).toEqual(["Merged feature/search into develop"]);
184188
});
185189

190+
it("prunes all worktrees after confirmation", async () => {
191+
const { runtime, calls } = makeRuntime();
192+
runtime.git = stubGit([
193+
{ path: "/repo", branch: "main", bare: false },
194+
{ path: "/repo/.worktrees/feature-search", branch: "feature/search", bare: false },
195+
{ path: "/repo/.worktrees/feature-api", branch: "feature/api", bare: false },
196+
]);
197+
const stdout: string[] = [];
198+
const confirmCalls: number[] = [];
199+
200+
const exitCode = await runWorktreeCommand(
201+
{
202+
command: "prune",
203+
args: [],
204+
projectDir: "/repo",
205+
port: 5111,
206+
},
207+
{
208+
createRuntime: () => runtime,
209+
confirmPrune: async (count) => {
210+
confirmCalls.push(count);
211+
return true;
212+
},
213+
stdout: (message) => stdout.push(message),
214+
},
215+
);
216+
217+
expect(exitCode).toBe(0);
218+
expect(confirmCalls).toEqual([2]);
219+
expect(calls).toEqual([{ method: "pruneWorktrees", value: null }]);
220+
expect(stdout).toEqual(["Pruned 2 worktrees: feature/search, feature/api"]);
221+
});
222+
223+
it("aborts prune when confirmation is declined", async () => {
224+
const { runtime, calls } = makeRuntime();
225+
runtime.git = stubGit([
226+
{ path: "/repo", branch: "main", bare: false },
227+
{ path: "/repo/.worktrees/feature-search", branch: "feature/search", bare: false },
228+
]);
229+
const stdout: string[] = [];
230+
231+
const exitCode = await runWorktreeCommand(
232+
{
233+
command: "prune",
234+
args: [],
235+
projectDir: "/repo",
236+
port: 5111,
237+
},
238+
{
239+
createRuntime: () => runtime,
240+
confirmPrune: async () => false,
241+
stdout: (message) => stdout.push(message),
242+
},
243+
);
244+
245+
expect(exitCode).toBe(0);
246+
expect(calls).toEqual([]);
247+
expect(stdout).toEqual(["Aborted."]);
248+
});
249+
186250
it("returns a failing exit code when lifecycle execution fails", async () => {
187251
const stdout: string[] = [];
188252
const stderr: string[] = [];
@@ -220,6 +284,9 @@ describe("runWorktreeCommand", () => {
220284
async mergeWorktree(): Promise<void> {
221285
throw new Error("not used");
222286
},
287+
async pruneWorktrees(): Promise<{ removedBranches: string[] }> {
288+
throw new Error("not used");
289+
},
223290
},
224291
}),
225292
stdout: (message) => stdout.push(message),
@@ -329,4 +396,24 @@ describe("runWorktreeCommand", () => {
329396
expect(createRuntimeCalled).toBe(false);
330397
expect(stdout).toEqual(["Usage:\n webmux list"]);
331398
});
399+
400+
it("prints prune help without creating a runtime", async () => {
401+
let createRuntimeCalled = false;
402+
const stdout: string[] = [];
403+
404+
const exitCode = await runWorktreeCommand(
405+
{ command: "prune", args: ["--help"], projectDir: "/repo", port: 5111 },
406+
{
407+
createRuntime: () => {
408+
createRuntimeCalled = true;
409+
throw new Error("unexpected");
410+
},
411+
stdout: (msg) => stdout.push(msg),
412+
},
413+
);
414+
415+
expect(exitCode).toBe(0);
416+
expect(createRuntimeCalled).toBe(false);
417+
expect(stdout).toEqual(["Usage:\n webmux prune"]);
418+
});
332419
});

0 commit comments

Comments
 (0)