authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 22:33:40-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 22:57:56-08:00
logdd912666f2d1d96081febd52000f01c80aa21420
treecfbf14d547f8e066e6ae11f75c2db71597d6c87b
parent521238cf8e1daf041e7541bb1acef8c347431079

Delete orphaned worktree when deleting a thread

- Prompt to remove the worktree when a deleted thread is its only reference - Best-effort cleanup via `api.git.removeWorktree` after thread deletion - Add `getOrphanedWorktreePathForThread` helper with unit tests

3 files changed, 131 insertions(+), 1 deletions(-)

apps/web/src/components/Sidebar.tsx+27-1
......@@ -12,6 +12,7 @@ import {
1212 type Thread,
1313} from "../types";
1414import { useNativeApi } from "../hooks/useNativeApi";
15import { getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1516
1617const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
1718function formatRelativeTime(iso: string): string {
......@@ -262,6 +263,18 @@ export default function Sidebar() {
262263
263264 const thread = state.threads.find((t) => t.id === threadId);
264265 if (!thread) return;
266 const threadProject = state.projects.find((project) => project.id === thread.projectId);
267 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
268 const shouldDeleteWorktree =
269 orphanedWorktreePath !== null &&
270 window.confirm(
271 [
272 "This thread is the only one linked to this worktree:",
273 orphanedWorktreePath,
274 "",
275 "Delete the worktree too?",
276 ].join("\n"),
277 );
265278
266279 // Stop active session if running
267280 if (thread.session?.sessionId) {
......@@ -284,8 +297,21 @@ export default function Sidebar() {
284297 }
285298
286299 dispatch({ type: "DELETE_THREAD", threadId });
300
301 if (!shouldDeleteWorktree || !orphanedWorktreePath) {
302 return;
303 }
304
305 try {
306 await api.git.removeWorktree({
307 cwd: threadProject?.cwd ?? orphanedWorktreePath,
308 path: orphanedWorktreePath,
309 });
310 } catch {
311 // Worktree deletion is best-effort and should not block thread deletion.
312 }
287313 },
288 [api, dispatch, state.threads],
314 [api, dispatch, state.projects, state.threads],
289315 );
290316
291317 useEffect(() => {
apps/web/src/worktreeCleanup.test.ts created+71
......@@ -0,0 +1,71 @@
1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
4import { getOrphanedWorktreePathForThread } from "./worktreeCleanup";
5
6function makeThread(overrides: Partial<Thread> = {}): Thread {
7 return {
8 id: "thread-1",
9 codexThreadId: null,
10 projectId: "project-1",
11 title: "Thread",
12 model: "gpt-5.3-codex",
13 terminalOpen: false,
14 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
15 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
16 runningTerminalIds: [],
17 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
18 terminalGroups: [
19 {
20 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
21 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
22 },
23 ],
24 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
25 session: null,
26 messages: [],
27 events: [],
28 error: null,
29 createdAt: "2026-02-13T00:00:00.000Z",
30 branch: null,
31 worktreePath: null,
32 ...overrides,
33 };
34}
35
36describe("getOrphanedWorktreePathForThread", () => {
37 it("returns null when the target thread does not exist", () => {
38 const result = getOrphanedWorktreePathForThread([], "missing-thread");
39 expect(result).toBeNull();
40 });
41
42 it("returns null when the target thread has no worktree", () => {
43 const threads = [makeThread()];
44 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
45 expect(result).toBeNull();
46 });
47
48 it("returns the path when no other thread links to that worktree", () => {
49 const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })];
50 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
51 expect(result).toBe("/tmp/repo/worktrees/feature-a");
52 });
53
54 it("returns null when another thread links to the same worktree", () => {
55 const threads = [
56 makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }),
57 makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-a" }),
58 ];
59 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
60 expect(result).toBeNull();
61 });
62
63 it("ignores threads linked to different worktrees", () => {
64 const threads = [
65 makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }),
66 makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-b" }),
67 ];
68 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
69 expect(result).toBe("/tmp/repo/worktrees/feature-a");
70 });
71});
apps/web/src/worktreeCleanup.ts created+33
......@@ -0,0 +1,33 @@
1import type { Thread } from "./types";
2
3function normalizeWorktreePath(path: string | null): string | null {
4 const trimmed = path?.trim();
5 if (!trimmed) {
6 return null;
7 }
8 return trimmed;
9}
10
11export function getOrphanedWorktreePathForThread(
12 threads: readonly Thread[],
13 threadId: string,
14): string | null {
15 const targetThread = threads.find((thread) => thread.id === threadId);
16 if (!targetThread) {
17 return null;
18 }
19
20 const targetWorktreePath = normalizeWorktreePath(targetThread.worktreePath);
21 if (!targetWorktreePath) {
22 return null;
23 }
24
25 const isShared = threads.some((thread) => {
26 if (thread.id === threadId) {
27 return false;
28 }
29 return normalizeWorktreePath(thread.worktreePath) === targetWorktreePath;
30 });
31
32 return isShared ? null : targetWorktreePath;
33}