Skip to content

Commit 382ea19

Browse files
authored
Prune merged worktrees after thread cleanup (#335)
* Clean up merged worktrees after thread deletion - Add merged worktree candidate detection and prune support - Auto-remove orphaned worktrees after merged thread cleanup - Surface cleanup state through the web client and WS contract * Add merged worktree cleanup dialog - Add a command-palette entry to review merged worktrees - Let users delete on-disk worktrees or prune stale Git records - Wire the dialog into the chat route and auto-delete flow * Improve merged worktree cleanup feedback - Use the active thread to choose the cleanup project - Rename prune action for stale records - Show an error toast when merged thread auto-delete fails * Format worktree cleanup and prune call - Reflow worktree cleanup dialog strings and layout - Reformat Git worktree prune call for consistency * Simplify merged worktree cleanup handling - Ignore prune failures silently during worktree cleanup - Add explicit git operation labels for merged-worktree cleanup calls
1 parent 661e0e6 commit 382ea19

13 files changed

Lines changed: 606 additions & 14 deletions

File tree

apps/server/src/git/Layers/GitCore.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,6 +1963,17 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
19631963
),
19641964
),
19651965
);
1966+
1967+
// Keep Git's worktree bookkeeping tidy after removing a checkout.
1968+
yield* executeGit(
1969+
"GitCore.removeWorktree.prune",
1970+
input.cwd,
1971+
["worktree", "prune", "--expire", "now"],
1972+
{
1973+
allowNonZeroExit: true,
1974+
timeoutMs: 15_000,
1975+
},
1976+
).pipe(Effect.catch(() => Effect.void));
19661977
});
19671978

19681979
const renameBranch: GitCoreShape["renameBranch"] = (input) =>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { realpathSync } from "node:fs";
2+
3+
export interface WorktreeCleanupCandidate {
4+
readonly path: string;
5+
readonly branch: string;
6+
readonly prNumber: number;
7+
readonly prTitle: string;
8+
readonly prUrl: string;
9+
readonly mergedAt: string;
10+
readonly pathExists: boolean;
11+
readonly prunable: boolean;
12+
}
13+
14+
interface WorktreeListEntry {
15+
path: string;
16+
branch: string | null;
17+
prunable: boolean;
18+
}
19+
20+
interface MergedPullRequestLike {
21+
readonly number: number;
22+
readonly title: string;
23+
readonly url: string;
24+
readonly headBranch: string;
25+
readonly mergedAt: string;
26+
}
27+
28+
function canonicalizePath(value: string): string {
29+
try {
30+
return realpathSync.native(value);
31+
} catch {
32+
return value.replace(/\\/g, "/").replace(/\/+$/, "");
33+
}
34+
}
35+
36+
function parseWorktreeList(stdout: string): WorktreeListEntry[] {
37+
const entries: WorktreeListEntry[] = [];
38+
let current: WorktreeListEntry | null = null;
39+
40+
for (const rawLine of stdout.split("\n")) {
41+
const line = rawLine.trimEnd();
42+
if (line.length === 0) {
43+
if (current) {
44+
entries.push(current);
45+
}
46+
current = null;
47+
continue;
48+
}
49+
50+
if (line.startsWith("worktree ")) {
51+
if (current) {
52+
entries.push(current);
53+
}
54+
current = {
55+
path: line.slice("worktree ".length).trim(),
56+
branch: null,
57+
prunable: false,
58+
};
59+
continue;
60+
}
61+
62+
if (!current) {
63+
continue;
64+
}
65+
66+
if (line.startsWith("branch refs/heads/")) {
67+
current.branch = line.slice("branch refs/heads/".length).trim() || null;
68+
continue;
69+
}
70+
71+
if (line.startsWith("prunable")) {
72+
current.prunable = true;
73+
}
74+
}
75+
76+
if (current) {
77+
entries.push(current);
78+
}
79+
80+
return entries;
81+
}
82+
83+
export function collectMergedWorktreeCleanupCandidates(input: {
84+
cwd: string;
85+
worktreeListStdout: string;
86+
mergedPullRequests: ReadonlyArray<MergedPullRequestLike>;
87+
}): WorktreeCleanupCandidate[] {
88+
const mergedPullRequestsByHeadBranch = new Map(
89+
input.mergedPullRequests.map((pr) => [pr.headBranch, pr] as const),
90+
);
91+
const rootPath = canonicalizePath(input.cwd);
92+
93+
return parseWorktreeList(input.worktreeListStdout)
94+
.flatMap((entry) => {
95+
const branch = entry.branch?.trim() ?? "";
96+
if (!branch) {
97+
return [];
98+
}
99+
const pullRequest = mergedPullRequestsByHeadBranch.get(branch);
100+
if (!pullRequest) {
101+
return [];
102+
}
103+
104+
const candidatePath = entry.path.trim();
105+
if (!candidatePath) {
106+
return [];
107+
}
108+
109+
if (canonicalizePath(candidatePath) === rootPath) {
110+
return [];
111+
}
112+
113+
return [
114+
{
115+
path: candidatePath,
116+
branch,
117+
prNumber: pullRequest.number,
118+
prTitle: pullRequest.title,
119+
prUrl: pullRequest.url,
120+
mergedAt: pullRequest.mergedAt,
121+
pathExists: !entry.prunable,
122+
prunable: entry.prunable,
123+
},
124+
];
125+
})
126+
.toSorted((a, b) => {
127+
if (a.pathExists !== b.pathExists) {
128+
return a.pathExists ? -1 : 1;
129+
}
130+
if (a.mergedAt !== b.mergedAt) {
131+
return b.mergedAt.localeCompare(a.mergedAt);
132+
}
133+
return a.branch.localeCompare(b.branch);
134+
});
135+
}

apps/server/src/wsServer.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { clamp } from "effect/Number";
6969
import { Open, resolveAvailableEditors } from "./open";
7070
import { ServerConfig } from "./config";
7171
import { GitCore } from "./git/Services/GitCore.ts";
72+
import { collectMergedWorktreeCleanupCandidates } from "./git/worktreeCleanup.ts";
7273
import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
7374
import {
7475
ATTACHMENTS_ROUTE_PREFIX,
@@ -1183,6 +1184,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
11831184
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
11841185
}
11851186

1187+
case WS_METHODS.gitListMergedWorktreeCleanupCandidates: {
1188+
const body = stripRequestTag(request.body);
1189+
const snapshot = yield* projectionReadModelQuery.getSnapshot();
1190+
const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot });
1191+
const mergedPullRequests = yield* gitManager
1192+
.listPullRequests({
1193+
cwd: body.cwd,
1194+
state: "merged",
1195+
limit: 500,
1196+
})
1197+
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
1198+
const worktreeList = yield* git
1199+
.execute({
1200+
operation: "GitCore.listMergedWorktreeCleanupCandidates.worktreeList",
1201+
cwd: body.cwd,
1202+
args: ["worktree", "list", "--porcelain"],
1203+
timeoutMs: 5_000,
1204+
allowNonZeroExit: true,
1205+
})
1206+
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
1207+
1208+
return collectMergedWorktreeCleanupCandidates({
1209+
cwd: body.cwd,
1210+
worktreeListStdout: worktreeList.stdout,
1211+
mergedPullRequests: mergedPullRequests.pullRequests.map((pr) => ({
1212+
number: pr.number,
1213+
title: pr.title,
1214+
url: pr.url,
1215+
headBranch: pr.headBranch,
1216+
mergedAt: pr.updatedAt,
1217+
})),
1218+
});
1219+
}
1220+
11861221
case WS_METHODS.prReviewGetConfig: {
11871222
const body = stripRequestTag(request.body);
11881223
yield* prReview
@@ -1341,6 +1376,20 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
13411376
return yield* git.removeWorktree(body).pipe(Effect.provideService(RuntimeEnv, gitEnv));
13421377
}
13431378

1379+
case WS_METHODS.gitPruneWorktrees: {
1380+
const body = stripRequestTag(request.body);
1381+
const snapshot = yield* projectionReadModelQuery.getSnapshot();
1382+
const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot });
1383+
return yield* git
1384+
.execute({
1385+
operation: "GitCore.pruneWorktrees",
1386+
cwd: body.cwd,
1387+
args: ["worktree", "prune", "--expire", "now"],
1388+
timeoutMs: 15_000,
1389+
})
1390+
.pipe(Effect.provideService(RuntimeEnv, gitEnv), Effect.asVoid);
1391+
}
1392+
13441393
case WS_METHODS.gitCreateBranch: {
13451394
const body = stripRequestTag(request.body);
13461395
const snapshot = yield* projectionReadModelQuery.getSnapshot();

apps/web/src/components/CommandPalette.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SettingsIcon,
1313
SunIcon,
1414
GitBranchIcon,
15+
GitMergeIcon,
1516
SearchIcon,
1617
KeyboardIcon,
1718
} from "lucide-react";
@@ -22,6 +23,7 @@ import { useStore } from "~/store";
2223
import { useCommandPaletteStore } from "~/commandPaletteStore";
2324
import { useHandleNewThread } from "~/hooks/useHandleNewThread";
2425
import { useTheme } from "~/hooks/useTheme";
26+
import { useWorktreeCleanupStore } from "~/worktreeCleanupStore";
2527
import {
2628
CommandDialog,
2729
CommandDialogPopup,
@@ -141,6 +143,7 @@ function CommandsView() {
141143
const pushMruProject = useCommandPaletteStore((s) => s.pushMruProject);
142144
const pushMruThread = useCommandPaletteStore((s) => s.pushMruThread);
143145
const mruThreadIds = useCommandPaletteStore((s) => s.mruThreadIds);
146+
const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog);
144147
const routeThreadId = useParams({
145148
strict: false,
146149
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
@@ -273,6 +276,19 @@ function CommandsView() {
273276
},
274277
});
275278

279+
cmds.push({
280+
id: "action-cleanup-merged-worktrees",
281+
label: "Review merged worktrees",
282+
keywords: ["cleanup", "delete", "prune", "merged", "worktree", "git"],
283+
icon: GitMergeIcon,
284+
group: "Actions",
285+
hidden: !currentProjectId,
286+
onSelect: () => {
287+
closePalette();
288+
openWorktreeCleanupDialog();
289+
},
290+
});
291+
276292
// ── Appearance ──
277293
cmds.push({
278294
id: "appearance-theme-light",
@@ -357,6 +373,7 @@ function CommandsView() {
357373
setTheme,
358374
pushMruProject,
359375
pushMruThread,
376+
openWorktreeCleanupDialog,
360377
]);
361378

362379
// Filter commands by query

0 commit comments

Comments
 (0)