diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 835afbb423f65182b49d01ca532eab49ff343d85..d4b569744cfe750d4d2f1855eae9007253a5b6ff 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -12,6 +12,7 @@ import { type Thread, } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; +import { getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; function formatRelativeTime(iso: string): string { @@ -262,6 +263,18 @@ export default function Sidebar() { const thread = state.threads.find((t) => t.id === threadId); if (!thread) return; + const threadProject = state.projects.find((project) => project.id === thread.projectId); + const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); + const shouldDeleteWorktree = + orphanedWorktreePath !== null && + window.confirm( + [ + "This thread is the only one linked to this worktree:", + orphanedWorktreePath, + "", + "Delete the worktree too?", + ].join("\n"), + ); // Stop active session if running if (thread.session?.sessionId) { @@ -284,8 +297,21 @@ export default function Sidebar() { } dispatch({ type: "DELETE_THREAD", threadId }); + + if (!shouldDeleteWorktree || !orphanedWorktreePath) { + return; + } + + try { + await api.git.removeWorktree({ + cwd: threadProject?.cwd ?? orphanedWorktreePath, + path: orphanedWorktreePath, + }); + } catch { + // Worktree deletion is best-effort and should not block thread deletion. + } }, - [api, dispatch, state.threads], + [api, dispatch, state.projects, state.threads], ); useEffect(() => { diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc86d7e8a30552faafd78151110be14dc4346eac --- /dev/null +++ b/apps/web/src/worktreeCleanup.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; +import { getOrphanedWorktreePathForThread } from "./worktreeCleanup"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: "thread-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: [], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + session: null, + messages: [], + events: [], + error: null, + createdAt: "2026-02-13T00:00:00.000Z", + branch: null, + worktreePath: null, + ...overrides, + }; +} + +describe("getOrphanedWorktreePathForThread", () => { + it("returns null when the target thread does not exist", () => { + const result = getOrphanedWorktreePathForThread([], "missing-thread"); + expect(result).toBeNull(); + }); + + it("returns null when the target thread has no worktree", () => { + const threads = [makeThread()]; + const result = getOrphanedWorktreePathForThread(threads, "thread-1"); + expect(result).toBeNull(); + }); + + it("returns the path when no other thread links to that worktree", () => { + const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })]; + const result = getOrphanedWorktreePathForThread(threads, "thread-1"); + expect(result).toBe("/tmp/repo/worktrees/feature-a"); + }); + + it("returns null when another thread links to the same worktree", () => { + const threads = [ + makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), + makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-a" }), + ]; + const result = getOrphanedWorktreePathForThread(threads, "thread-1"); + expect(result).toBeNull(); + }); + + it("ignores threads linked to different worktrees", () => { + const threads = [ + makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), + makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-b" }), + ]; + const result = getOrphanedWorktreePathForThread(threads, "thread-1"); + expect(result).toBe("/tmp/repo/worktrees/feature-a"); + }); +}); diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts new file mode 100644 index 0000000000000000000000000000000000000000..b114814006e298443ab92f8dad9b161a651e6a4e --- /dev/null +++ b/apps/web/src/worktreeCleanup.ts @@ -0,0 +1,33 @@ +import type { Thread } from "./types"; + +function normalizeWorktreePath(path: string | null): string | null { + const trimmed = path?.trim(); + if (!trimmed) { + return null; + } + return trimmed; +} + +export function getOrphanedWorktreePathForThread( + threads: readonly Thread[], + threadId: string, +): string | null { + const targetThread = threads.find((thread) => thread.id === threadId); + if (!targetThread) { + return null; + } + + const targetWorktreePath = normalizeWorktreePath(targetThread.worktreePath); + if (!targetWorktreePath) { + return null; + } + + const isShared = threads.some((thread) => { + if (thread.id === threadId) { + return false; + } + return normalizeWorktreePath(thread.worktreePath) === targetWorktreePath; + }); + + return isShared ? null : targetWorktreePath; +}