From e54e9d448587e7eb67d9d8b31e5a4484a2651adb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 7 Apr 2026 18:54:43 -0700 Subject: [PATCH 1/3] Move folder cleanup and orphaned worktree pruning to FoldersService.initialize --- .../code/src/main/services/folders/schemas.ts | 20 ------- .../src/main/services/folders/service.test.ts | 8 +-- .../code/src/main/services/folders/service.ts | 45 ++++++++++++---- apps/code/src/main/trpc/routers/folders.ts | 9 ---- .../features/folders/hooks/useFolders.ts | 52 +------------------ 5 files changed, 41 insertions(+), 93 deletions(-) diff --git a/apps/code/src/main/services/folders/schemas.ts b/apps/code/src/main/services/folders/schemas.ts index 847c4848d..6febbd158 100644 --- a/apps/code/src/main/services/folders/schemas.ts +++ b/apps/code/src/main/services/folders/schemas.ts @@ -29,20 +29,6 @@ export const updateFolderAccessedInput = z.object({ folderId: z.string(), }); -export const cleanupOrphanedWorktreesInput = z.object({ - mainRepoPath: z.string(), -}); - -export const cleanupOrphanedWorktreesOutput = z.object({ - deleted: z.array(z.string()), - errors: z.array( - z.object({ - path: z.string(), - error: z.string(), - }), - ), -}); - export type RegisteredFolder = z.infer; export type GetFoldersOutput = z.infer; export type AddFolderInput = z.infer; @@ -51,12 +37,6 @@ export type RemoveFolderInput = z.infer; export type UpdateFolderAccessedInput = z.infer< typeof updateFolderAccessedInput >; -export type CleanupOrphanedWorktreesInput = z.infer< - typeof cleanupOrphanedWorktreesInput ->; -export type CleanupOrphanedWorktreesOutput = z.infer< - typeof cleanupOrphanedWorktreesOutput ->; export const repositoryLookupResult = z .object({ diff --git a/apps/code/src/main/services/folders/service.test.ts b/apps/code/src/main/services/folders/service.test.ts index fd36101e9..65e3ae74f 100644 --- a/apps/code/src/main/services/folders/service.test.ts +++ b/apps/code/src/main/services/folders/service.test.ts @@ -318,11 +318,11 @@ describe("FoldersService", () => { errors: [], }); - const result = - await service.cleanupOrphanedWorktrees("/home/user/project"); + await service.cleanupOrphanedWorktrees("/home/user/project"); - expect(result.deleted).toHaveLength(1); - expect(result.errors).toHaveLength(0); + expect(mockWorktreeManager.cleanupOrphanedWorktrees).toHaveBeenCalledWith( + [], + ); }); it("excludes associated worktrees from cleanup", async () => { diff --git a/apps/code/src/main/services/folders/service.ts b/apps/code/src/main/services/folders/service.ts index 18b510fd8..7d199fc5c 100644 --- a/apps/code/src/main/services/folders/service.ts +++ b/apps/code/src/main/services/folders/service.ts @@ -26,10 +26,7 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { getMainWindow } from "../../trpc/context"; import { logger } from "../../utils/logger"; import { getWorktreeLocation } from "../settingsStore"; -import type { - CleanupOrphanedWorktreesOutput, - RegisteredFolder, -} from "./schemas"; +import type { RegisteredFolder } from "./schemas"; const log = logger.scope("folders-service"); @@ -42,7 +39,39 @@ export class FoldersService { private readonly workspaceRepo: IWorkspaceRepository, @inject(MAIN_TOKENS.WorktreeRepository) private readonly worktreeRepo: IWorktreeRepository, - ) {} + ) { + this.initialize().catch((err) => { + log.error("Folders initialization failed", err); + }); + } + + private async initialize(): Promise { + const folders = await this.getFolders(); + + const deletedFolders = folders.filter((f) => !f.exists); + if (deletedFolders.length > 0) { + for (const folder of deletedFolders) { + try { + await this.removeFolder(folder.id); + } catch (err) { + log.error(`Failed to remove deleted folder ${folder.path}:`, err); + } + } + log.info(`Removed ${deletedFolders.length} deleted folder(s)`); + } + + const existingFolders = folders.filter((f) => f.exists); + for (const folder of existingFolders) { + try { + await this.cleanupOrphanedWorktrees(folder.path); + } catch (err) { + log.error( + `Failed to cleanup orphaned worktrees for ${folder.path}:`, + err, + ); + } + } + } async getFolders(): Promise<(RegisteredFolder & { exists: boolean })[]> { const repos = this.repositoryRepo.findAll(); @@ -190,16 +219,14 @@ export class FoldersService { this.repositoryRepo.updateLastAccessed(folderId); } - async cleanupOrphanedWorktrees( - mainRepoPath: string, - ): Promise { + async cleanupOrphanedWorktrees(mainRepoPath: string): Promise { const worktreeBasePath = getWorktreeLocation(); const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); const allWorktrees = this.worktreeRepo.findAll(); const associatedWorktreePaths = allWorktrees.map((wt) => wt.path); - return await manager.cleanupOrphanedWorktrees(associatedWorktreePaths); + await manager.cleanupOrphanedWorktrees(associatedWorktreePaths); } getRepositoryByRemoteUrl( diff --git a/apps/code/src/main/trpc/routers/folders.ts b/apps/code/src/main/trpc/routers/folders.ts index 1ddc80571..7b30d871e 100644 --- a/apps/code/src/main/trpc/routers/folders.ts +++ b/apps/code/src/main/trpc/routers/folders.ts @@ -3,8 +3,6 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { addFolderInput, addFolderOutput, - cleanupOrphanedWorktreesInput, - cleanupOrphanedWorktreesOutput, getFoldersOutput, getRepositoryByRemoteUrlInput, removeFolderInput, @@ -41,13 +39,6 @@ export const foldersRouter = router({ return getService().updateFolderAccessed(input.folderId); }), - cleanupOrphanedWorktrees: publicProcedure - .input(cleanupOrphanedWorktreesInput) - .output(cleanupOrphanedWorktreesOutput) - .mutation(({ input }) => { - return getService().cleanupOrphanedWorktrees(input.mainRepoPath); - }), - clearAllData: publicProcedure.mutation(() => { return getService().clearAllData(); }), diff --git a/apps/code/src/renderer/features/folders/hooks/useFolders.ts b/apps/code/src/renderer/features/folders/hooks/useFolders.ts index 5d68aa01b..7a6d56a72 100644 --- a/apps/code/src/renderer/features/folders/hooks/useFolders.ts +++ b/apps/code/src/renderer/features/folders/hooks/useFolders.ts @@ -1,17 +1,12 @@ import type { RegisteredFolder } from "@main/services/folders/schemas"; -import { useFocusStore } from "@renderer/stores/focusStore"; import { trpc, trpcClient, useTRPC } from "@renderer/trpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; import { queryClient } from "@utils/queryClient"; -import { useCallback, useEffect, useMemo, useRef } from "react"; - -const log = logger.scope("folders"); +import { useCallback, useMemo } from "react"; export function useFolders() { const trpcReact = useTRPC(); const queryClient = useQueryClient(); - const hasInitialized = useRef(false); const { data: folders = [], isLoading } = useQuery( trpcReact.folders.getFolders.queryOptions(undefined, { @@ -24,46 +19,6 @@ export function useFolders() { [folders], ); - useEffect(() => { - if (hasInitialized.current || isLoading || folders.length === 0) return; - hasInitialized.current = true; - - const deletedFolders = folders.filter((f) => f.exists === false); - if (deletedFolders.length > 0) { - Promise.all( - deletedFolders.map((folder) => - trpcClient.folders.removeFolder - .mutate({ folderId: folder.id }) - .catch((err) => - log.error(`Failed to remove deleted folder ${folder.path}:`, err), - ), - ), - ).then(() => { - void queryClient.invalidateQueries( - trpcReact.folders.getFolders.pathFilter(), - ); - }); - } - - for (const folder of existingFolders) { - useFocusStore - .getState() - .restore(folder.path) - .catch((error) => { - log.error(`Failed to restore focus state for ${folder.path}:`, error); - }); - - trpcClient.folders.cleanupOrphanedWorktrees - .mutate({ mainRepoPath: folder.path }) - .catch((error) => { - log.error( - `Failed to cleanup orphaned worktrees for ${folder.path}:`, - error, - ); - }); - } - }, [folders, existingFolders, isLoading, queryClient, trpcReact]); - const addFolderMutation = useMutation( trpcReact.folders.addFolder.mutationOptions({ onSuccess: () => { @@ -177,11 +132,6 @@ export const foldersApi = { async updateFolderAccessed(folderId: string) { return trpcClient.folders.updateFolderAccessed.mutate({ folderId }); }, - async cleanupOrphanedWorktrees(mainRepoPath: string) { - return trpcClient.folders.cleanupOrphanedWorktrees.mutate({ - mainRepoPath, - }); - }, getFolderByPath(folders: RegisteredFolder[], path: string) { return folders.find((f) => f.path === path); }, From 1900967a109e89ce93cc76186e64aaddc72ae158 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 7 Apr 2026 20:04:11 -0700 Subject: [PATCH 2/3] Parallelize worktree cleanup and add initialize tests --- .../src/main/services/folders/service.test.ts | 131 ++++++++++++++++++ .../code/src/main/services/folders/service.ts | 15 +- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/apps/code/src/main/services/folders/service.test.ts b/apps/code/src/main/services/folders/service.test.ts index 65e3ae74f..326000c90 100644 --- a/apps/code/src/main/services/folders/service.test.ts +++ b/apps/code/src/main/services/folders/service.test.ts @@ -128,6 +128,137 @@ describe("FoldersService", () => { vi.clearAllMocks(); }); + describe("initialize", () => { + function createService() { + return new FoldersService( + mockRepositoryRepo as unknown as IRepositoryRepository, + mockWorkspaceRepo as unknown as IWorkspaceRepository, + mockWorktreeRepo as unknown as IWorktreeRepository, + ); + } + + it("removes folders that no longer exist on disk", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/gone/project", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(false); + mockRepositoryRepo.findById.mockReturnValue({ + id: "folder-1", + path: "/gone/project", + }); + mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); + + createService(); + await vi.waitFor(() => { + expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-1"); + }); + }); + + it("cleans up orphaned worktrees for each existing folder", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/home/user/project-a", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + { + id: "folder-2", + path: "/home/user/project-b", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(true); + mockWorktreeRepo.findAll.mockReturnValue([]); + mockWorktreeManager.cleanupOrphanedWorktrees.mockResolvedValue({ + deleted: [], + errors: [], + }); + + createService(); + await vi.waitFor(() => { + expect( + mockWorktreeManager.cleanupOrphanedWorktrees, + ).toHaveBeenCalledTimes(2); + }); + }); + + it("continues if one folder removal fails", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/gone/a", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + { + id: "folder-2", + path: "/gone/b", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(false); + mockRepositoryRepo.findById + .mockReturnValueOnce({ id: "folder-1", path: "/gone/a" }) + .mockReturnValueOnce({ id: "folder-2", path: "/gone/b" }); + mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); + mockRepositoryRepo.delete + .mockImplementationOnce(() => { + throw new Error("db error"); + }) + .mockImplementationOnce(() => undefined); + + createService(); + await vi.waitFor(() => { + expect(mockRepositoryRepo.delete).toHaveBeenCalledTimes(2); + expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-2"); + }); + }); + + it("continues if one worktree cleanup fails", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/home/user/project-a", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + { + id: "folder-2", + path: "/home/user/project-b", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(true); + mockWorktreeRepo.findAll.mockReturnValue([]); + mockWorktreeManager.cleanupOrphanedWorktrees + .mockRejectedValueOnce(new Error("cleanup error")) + .mockResolvedValueOnce({ deleted: [], errors: [] }); + + createService(); + await vi.waitFor(() => { + expect( + mockWorktreeManager.cleanupOrphanedWorktrees, + ).toHaveBeenCalledTimes(2); + }); + }); + }); + describe("getFolders", () => { it("returns empty array when no folders registered", async () => { mockRepositoryRepo.findAll.mockReturnValue([]); diff --git a/apps/code/src/main/services/folders/service.ts b/apps/code/src/main/services/folders/service.ts index 7d199fc5c..d9de671cb 100644 --- a/apps/code/src/main/services/folders/service.ts +++ b/apps/code/src/main/services/folders/service.ts @@ -61,13 +61,16 @@ export class FoldersService { } const existingFolders = folders.filter((f) => f.exists); - for (const folder of existingFolders) { - try { - await this.cleanupOrphanedWorktrees(folder.path); - } catch (err) { + const results = await Promise.allSettled( + existingFolders.map((folder) => + this.cleanupOrphanedWorktrees(folder.path), + ), + ); + for (const [i, result] of results.entries()) { + if (result.status === "rejected") { log.error( - `Failed to cleanup orphaned worktrees for ${folder.path}:`, - err, + `Failed to cleanup orphaned worktrees for ${existingFolders[i].path}:`, + result.reason, ); } } From 6efaa2ac87d541c73019b944ee71b335bd2b1983 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 7 Apr 2026 20:36:34 -0700 Subject: [PATCH 3/3] Track actual removal count in initialize log --- apps/code/src/main/services/folders/service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/code/src/main/services/folders/service.ts b/apps/code/src/main/services/folders/service.ts index d9de671cb..43f64adc2 100644 --- a/apps/code/src/main/services/folders/service.ts +++ b/apps/code/src/main/services/folders/service.ts @@ -50,14 +50,18 @@ export class FoldersService { const deletedFolders = folders.filter((f) => !f.exists); if (deletedFolders.length > 0) { + let removed = 0; for (const folder of deletedFolders) { try { await this.removeFolder(folder.id); + removed++; } catch (err) { log.error(`Failed to remove deleted folder ${folder.path}:`, err); } } - log.info(`Removed ${deletedFolders.length} deleted folder(s)`); + if (removed > 0) { + log.info(`Removed ${removed} deleted folder(s)`); + } } const existingFolders = folders.filter((f) => f.exists);