Skip to content

Commit 8f95086

Browse files
authored
Add bulk delete-all worktree cleanup action (#344)
* Add delete-all worktree cleanup action - Hide cleanup entry points when no candidates exist - Share cleanup candidate resolution across the sidebar, palette, and dialog - Add delete-all handling for on-disk worktrees and stale Git records * Cache sidebar thread and project lookups (#341) - group and sort threads by project once for faster sidebar interactions - preserve persisted project expansion state with explicit per-CWD data - add store tests for legacy and explicit sidebar state parsing * Add unified workspace panel to right sidebar (#343) - Replace legacy files/editor tabs with a single workspace view - Add responsive stacked/split workspace layout for tree and editor - Normalize persisted right-panel tabs and update related labels * Test force removal of missing git worktree records - Cover the case where a worktree directory is deleted before `removeWorktree(..., force: true)` runs - Verify the stale record is removed from `git worktree list` * Tighten worktree cleanup sidebar typing - Align sidebar project ids with thread project ids - Accept readonly project thread ordering - Simplify worktree cleanup dialog separator rendering
1 parent 9980ac0 commit 8f95086

8 files changed

Lines changed: 366 additions & 88 deletions

File tree

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,33 @@ it.layer(TestLayer)("git integration", (it) => {
11901190
expect(existsSync(wtPath)).toBe(false);
11911191
}),
11921192
);
1193+
1194+
it.effect("removeGitWorktree force removes a missing worktree record", () =>
1195+
Effect.gen(function* () {
1196+
const tmp = yield* makeTmpDir();
1197+
yield* initRepoWithCommit(tmp);
1198+
1199+
const wtPath = path.join(tmp, "wt-missing-dir");
1200+
const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1201+
(b) => b.current,
1202+
)!.name;
1203+
1204+
yield* (yield* GitCore).createWorktree({
1205+
cwd: tmp,
1206+
branch: currentBranch,
1207+
newBranch: "wt-missing",
1208+
path: wtPath,
1209+
});
1210+
expect(existsSync(wtPath)).toBe(true);
1211+
1212+
fs.rmSync(wtPath, { recursive: true, force: true });
1213+
1214+
yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true });
1215+
1216+
const worktreeList = yield* git(tmp, ["worktree", "list", "--porcelain"]);
1217+
expect(worktreeList).not.toContain(wtPath);
1218+
}),
1219+
);
11931220
});
11941221

11951222
// ── Full flow: local branch checkout ──

apps/web/src/components/CommandPalette.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { cn } from "~/lib/utils";
2222
import { useStore } from "~/store";
2323
import { useCommandPaletteStore } from "~/commandPaletteStore";
2424
import { useHandleNewThread } from "~/hooks/useHandleNewThread";
25+
import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates";
2526
import { useTheme } from "~/hooks/useTheme";
2627
import { useWorktreeCleanupStore } from "~/worktreeCleanupStore";
2728
import {
@@ -144,6 +145,7 @@ function CommandsView() {
144145
const pushMruThread = useCommandPaletteStore((s) => s.pushMruThread);
145146
const mruThreadIds = useCommandPaletteStore((s) => s.mruThreadIds);
146147
const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog);
148+
const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates();
147149
const routeThreadId = useParams({
148150
strict: false,
149151
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
@@ -282,7 +284,7 @@ function CommandsView() {
282284
keywords: ["cleanup", "delete", "prune", "merged", "worktree", "git"],
283285
icon: GitMergeIcon,
284286
group: "Actions",
285-
hidden: !currentProjectId,
287+
hidden: !currentProjectId || !hasWorktreeCleanupCandidates,
286288
onSelect: () => {
287289
closePalette();
288290
openWorktreeCleanupDialog();
@@ -374,6 +376,7 @@ function CommandsView() {
374376
pushMruProject,
375377
pushMruThread,
376378
openWorktreeCleanupDialog,
379+
hasWorktreeCleanupCandidates,
377380
]);
378381

379382
// Filter commands by query

apps/web/src/components/Sidebar.logic.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
1111
export type SidebarNewThreadEnvMode = "local" | "worktree";
1212
type SidebarProject = {
13-
id: string;
13+
id: Thread["projectId"];
1414
name: string;
1515
createdAt?: string | undefined;
1616
updatedAt?: string | undefined;

apps/web/src/components/Sidebar.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog";
5050
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
5151
import { useClientMode } from "~/hooks/useClientMode";
5252
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
53+
import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates";
5354
import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor";
5455
import { useTheme } from "~/hooks/useTheme";
5556
import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
@@ -290,7 +291,7 @@ interface MemoizedThreadRowProps {
290291
isActive: boolean;
291292
isSelected: boolean;
292293
prByThreadId: Map<ThreadIdType, ThreadPr>;
293-
orderedProjectThreadIds: ThreadIdType[];
294+
orderedProjectThreadIds: readonly ThreadIdType[];
294295
selectedThreadIds: ReadonlySet<ThreadIdType>;
295296
editingThreadId: ThreadIdType | null;
296297
editingThreadTitle: string;
@@ -489,6 +490,7 @@ export default function Sidebar() {
489490
...serverConfigQueryOptions(),
490491
select: (config) => config.keybindings,
491492
});
493+
const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates();
492494
const queryClient = useQueryClient();
493495
const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
494496
const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog);
@@ -2060,16 +2062,18 @@ export default function Sidebar() {
20602062
<span className="text-xs">Open Workspace</span>
20612063
</SidebarMenuButton>
20622064
</SidebarMenuItem>
2063-
<SidebarMenuItem>
2064-
<SidebarMenuButton
2065-
size="sm"
2066-
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2067-
onClick={() => openWorktreeCleanupDialog()}
2068-
>
2069-
<GitMergeIcon className="size-3.5" />
2070-
<span className="text-xs">Worktree cleanup</span>
2071-
</SidebarMenuButton>
2072-
</SidebarMenuItem>
2065+
{hasWorktreeCleanupCandidates ? (
2066+
<SidebarMenuItem>
2067+
<SidebarMenuButton
2068+
size="sm"
2069+
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2070+
onClick={() => openWorktreeCleanupDialog()}
2071+
>
2072+
<GitMergeIcon className="size-3.5" />
2073+
<span className="text-xs">Worktree cleanup</span>
2074+
</SidebarMenuButton>
2075+
</SidebarMenuItem>
2076+
) : null}
20732077
<SidebarMenuItem>
20742078
<SidebarMenuButton
20752079
size="sm"

0 commit comments

Comments
 (0)