Skip to content

Commit 326450d

Browse files
committed
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
1 parent 661e0e6 commit 326450d

10 files changed

Lines changed: 359 additions & 13 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,6 +1963,24 @@ 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("GitCore.removeWorktree.prune", input.cwd, [
1969+
"worktree",
1970+
"prune",
1971+
"--expire",
1972+
"now",
1973+
], {
1974+
allowNonZeroExit: true,
1975+
timeoutMs: 15_000,
1976+
}).pipe(
1977+
Effect.catchAll((error) =>
1978+
Effect.logWarning("GitCore.removeWorktree: worktree prune failed", {
1979+
cwd: input.cwd,
1980+
error: error instanceof Error ? error.message : String(error),
1981+
}).pipe(Effect.asVoid),
1982+
),
1983+
);
19661984
});
19671985

19681986
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: 47 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,39 @@ 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+
cwd: body.cwd,
1201+
args: ["worktree", "list", "--porcelain"],
1202+
timeoutMs: 5_000,
1203+
allowNonZeroExit: true,
1204+
})
1205+
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
1206+
1207+
return collectMergedWorktreeCleanupCandidates({
1208+
cwd: body.cwd,
1209+
worktreeListStdout: worktreeList.stdout,
1210+
mergedPullRequests: mergedPullRequests.pullRequests.map((pr) => ({
1211+
number: pr.number,
1212+
title: pr.title,
1213+
url: pr.url,
1214+
headBranch: pr.headBranch,
1215+
mergedAt: pr.updatedAt,
1216+
})),
1217+
});
1218+
}
1219+
11861220
case WS_METHODS.prReviewGetConfig: {
11871221
const body = stripRequestTag(request.body);
11881222
yield* prReview
@@ -1341,6 +1375,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
13411375
return yield* git.removeWorktree(body).pipe(Effect.provideService(RuntimeEnv, gitEnv));
13421376
}
13431377

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

apps/web/src/hooks/useAutoDeleteMergedThreads.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { useEffect, useRef } from "react";
2-
import { useQueries } from "@tanstack/react-query";
2+
import { useQueries, useQueryClient } from "@tanstack/react-query";
33
import type { ThreadId } from "@okcode/contracts";
44

55
import type { AppSettings } from "../appSettings";
6-
import { gitStatusQueryOptions } from "../lib/gitReactQuery";
6+
import { gitQueryKeys, gitStatusQueryOptions } from "../lib/gitReactQuery";
77
import { readNativeApi } from "../nativeApi";
88
import { newCommandId } from "../lib/utils";
99
import { useStore } from "../store";
10+
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1011
import { toastManager } from "../components/ui/toast";
1112

1213
/**
@@ -33,6 +34,7 @@ interface MergedThreadTimer {
3334
export function useAutoDeleteMergedThreads(settings: AppSettings) {
3435
const threads = useStore((store) => store.threads);
3536
const projects = useStore((store) => store.projects);
37+
const queryClient = useQueryClient();
3638

3739
// Track active timers per thread so we can cancel on setting change or
3840
// unmount, and avoid double-scheduling.
@@ -72,11 +74,14 @@ export function useAutoDeleteMergedThreads(settings: AppSettings) {
7274
for (let i = 0; i < threads.length; i++) {
7375
const thread = threads[i]!;
7476
const prState = statusQueries[i]?.data?.pr?.state;
77+
const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, thread.id);
7578

76-
if (prState === "merged" && !timersRef.current.has(thread.id)) {
79+
if (prState === "merged" && orphanedWorktreePath && !timersRef.current.has(thread.id)) {
7780
// PR just detected as merged – start countdown.
7881
const threadTitle = thread.title || `Thread ${thread.id.slice(0, 8)}`;
7982
const minutesLabel = delayMinutes === 1 ? "1 minute" : `${delayMinutes} minutes`;
83+
const threadProject = projects.find((project) => project.id === thread.projectId) ?? null;
84+
const displayWorktreePath = formatWorktreePathForDisplay(orphanedWorktreePath);
8085

8186
const toastId = toastManager.add({
8287
type: "info",
@@ -101,20 +106,33 @@ export function useAutoDeleteMergedThreads(settings: AppSettings) {
101106
});
102107

103108
const timeoutId = setTimeout(() => {
104-
void deleteThreadById(thread.id);
105-
timersRef.current.delete(thread.id);
106-
toastManager.add({
107-
type: "success",
108-
title: "Merged thread deleted",
109-
description: `"${threadTitle}" was auto-deleted after its PR was merged.`,
110-
});
109+
void (async () => {
110+
try {
111+
await deleteThreadById(thread.id);
112+
if (threadProject && orphanedWorktreePath) {
113+
await removeOrphanedWorktree({
114+
threadId: thread.id,
115+
projectCwd: threadProject.cwd,
116+
worktreePath: orphanedWorktreePath,
117+
});
118+
}
119+
toastManager.add({
120+
type: "success",
121+
title: "Merged thread deleted",
122+
description: `"${threadTitle}" was auto-deleted after its PR was merged.`,
123+
});
124+
} finally {
125+
timersRef.current.delete(thread.id);
126+
void queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
127+
}
128+
})();
111129
}, delayMs);
112130

113131
timersRef.current.set(thread.id, { timeoutId, toastId });
114132
}
115133

116134
// If a timer exists but the thread is gone (deleted externally), clean up.
117-
if (prState !== "merged" && timersRef.current.has(thread.id)) {
135+
if ((prState !== "merged" || !orphanedWorktreePath) && timersRef.current.has(thread.id)) {
118136
const timer = timersRef.current.get(thread.id)!;
119137
clearTimeout(timer.timeoutId);
120138
if (timer.toastId !== null) {
@@ -184,3 +202,33 @@ async function deleteThreadById(threadId: ThreadId): Promise<void> {
184202
threadId,
185203
});
186204
}
205+
206+
async function removeOrphanedWorktree(input: {
207+
threadId: ThreadId;
208+
projectCwd: string;
209+
worktreePath: string;
210+
}): Promise<void> {
211+
const api = readNativeApi();
212+
if (!api) return;
213+
214+
try {
215+
await api.git.removeWorktree({
216+
cwd: input.projectCwd,
217+
path: input.worktreePath,
218+
force: true,
219+
});
220+
} catch (error) {
221+
const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
222+
console.error("Failed to remove orphaned worktree after merged-thread auto-delete", {
223+
threadId: input.threadId,
224+
projectCwd: input.projectCwd,
225+
worktreePath: input.worktreePath,
226+
error,
227+
});
228+
toastManager.add({
229+
type: "error",
230+
title: "Thread deleted, but worktree removal failed",
231+
description: `Could not remove ${formatWorktreePathForDisplay(input.worktreePath)}. ${message}`,
232+
});
233+
}
234+
}

apps/web/src/lib/gitReactQuery.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GitStackedAction } from "@okcode/contracts";
1+
import type { GitStackedAction, GitWorktreeCleanupCandidate } from "@okcode/contracts";
22
import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
33
import { ensureNativeApi } from "../nativeApi";
44

@@ -12,6 +12,8 @@ export const gitQueryKeys = {
1212
status: (cwd: string | null) => ["git", "status", cwd] as const,
1313
branches: (cwd: string | null) => ["git", "branches", cwd] as const,
1414
pullRequests: (cwd: string | null) => ["git", "pull-requests", cwd] as const,
15+
mergedWorktreeCleanupCandidates: (cwd: string | null) =>
16+
["git", "merged-worktree-cleanup-candidates", cwd] as const,
1517
};
1618

1719
export const gitMutationKeys = {
@@ -243,6 +245,36 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie
243245
});
244246
}
245247

248+
export function gitPruneWorktreesMutationOptions(input: { queryClient: QueryClient }) {
249+
return mutationOptions({
250+
mutationFn: async ({ cwd }: { cwd: string }) => {
251+
const api = ensureNativeApi();
252+
if (!cwd) throw new Error("Git worktree pruning is unavailable.");
253+
return api.git.pruneWorktrees({ cwd });
254+
},
255+
mutationKey: ["git", "mutation", "prune-worktrees"] as const,
256+
onSettled: async () => {
257+
await invalidateGitQueries(input.queryClient);
258+
},
259+
});
260+
}
261+
262+
export function gitMergedWorktreeCleanupCandidatesQueryOptions(cwd: string | null) {
263+
return queryOptions({
264+
queryKey: gitQueryKeys.mergedWorktreeCleanupCandidates(cwd),
265+
queryFn: async (): Promise<ReadonlyArray<GitWorktreeCleanupCandidate>> => {
266+
const api = ensureNativeApi();
267+
if (!cwd) throw new Error("Merged worktree cleanup is unavailable.");
268+
return api.git.listMergedWorktreeCleanupCandidates({ cwd });
269+
},
270+
enabled: cwd !== null,
271+
staleTime: 15_000,
272+
refetchOnWindowFocus: true,
273+
refetchOnReconnect: true,
274+
refetchInterval: 30_000,
275+
});
276+
}
277+
246278
export function gitCloneRepositoryMutationOptions(input: { queryClient: QueryClient }) {
247279
return mutationOptions({
248280
mutationKey: ["git", "mutation", "clone-repository"] as const,

0 commit comments

Comments
 (0)