From 399a7e736a1164bd64315beda4a1e12be15f63a8 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:37:16 +0100 Subject: [PATCH] perf(frontend): async parallel worktree listing to prevent UI freezes (#1553) Replace ~160 synchronous blocking git calls with async parallel execution when listing task worktrees. Key changes: - Add includeStats option to listWorktrees IPC handler (default: false) - Convert processWorktreeEntry from execFileSync to execFileAsync - Process all worktrees in parallel via Promise.allSettled - Cache project default branch detection (once per project, not per worktree) - WorktreeSelector passes includeStats: false for fast dropdown listing - Worktrees page passes includeStats: true for full stats display - Make WorktreeListItem stats fields optional to support both modes - Fix detection guard to also run when mainBranch setting is invalid - Downgrade cli-tool-manager cache-hit log from warn to debug Co-authored-by: Claude Opus 4.5 --- apps/frontend/src/main/cli-tool-manager.ts | 4 +- .../ipc-handlers/task/worktree-handlers.ts | 162 ++++++++++++------ apps/frontend/src/preload/api/task-api.ts | 6 +- .../src/renderer/components/Worktrees.tsx | 20 +-- .../components/terminal/WorktreeSelector.tsx | 2 +- apps/frontend/src/shared/types/ipc.ts | 2 +- apps/frontend/src/shared/types/task.ts | 8 +- 7 files changed, 126 insertions(+), 78 deletions(-) diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index d1bb0f85..f749160e 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -307,7 +307,7 @@ class CLIToolManager { // Check cache first const cached = this.cache.get(tool); if (cached) { - console.warn( + console.debug( `[CLI Tools] Using cached ${tool}: ${cached.path} (${cached.source})` ); return cached.path; @@ -1033,7 +1033,7 @@ class CLIToolManager { // Check cache first (instant return if cached) const cached = this.cache.get(tool); if (cached) { - console.warn( + console.debug( `[CLI Tools] Using cached ${tool}: ${cached.path} (${cached.source})` ); return cached.path; diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 6be452f4..545d8d5c 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -2660,101 +2660,149 @@ export function registerWorktreeHandlers( /** * List all spec worktrees for a project * Per-spec architecture: Each spec has its own worktree at .auto-claude/worktrees/tasks/{spec-name}/ + * + * Options: + * - includeStats: When true, fetches commit count, files changed, additions, deletions per worktree. + * When false (default), only fetches branch and base branch info for faster listing. */ ipcMain.handle( IPC_CHANNELS.TASK_LIST_WORKTREES, - async (_, projectId: string): Promise> => { + async (_, projectId: string, options?: { includeStats?: boolean }): Promise> => { try { const project = projectStore.getProject(projectId); if (!project) { return { success: false, error: 'Project not found' }; } - const worktrees: WorktreeListItem[] = []; + const includeStats = options?.includeStats ?? false; const worktreesDir = getTaskWorktreeDir(project.path); + const gitPath = getToolPath('git'); - // Helper to process a single worktree entry - const processWorktreeEntry = (entry: string, entryPath: string) => { + // Detect the project's default branch once (main/master) instead of per-worktree + let projectDefaultBranch: string | undefined; + if (!project.settings?.mainBranch || !GIT_BRANCH_REGEX.test(project.settings.mainBranch)) { + for (const branch of ['main', 'master']) { + try { + await execFileAsync(gitPath, ['rev-parse', '--verify', branch], { + cwd: project.path, + encoding: 'utf-8', + }); + projectDefaultBranch = branch; + break; + } catch { + // Branch doesn't exist, try next + } + } + if (!projectDefaultBranch) { + projectDefaultBranch = 'main'; // fallback + } + } + // Helper to get effective base branch using cached project default + const getBaseBranchForEntry = (entry: string): string => { + // 1. Try task metadata baseBranch + const specDir = path.join(project.path, '.auto-claude', 'specs', entry); + const taskBaseBranch = getTaskBaseBranch(specDir); + if (taskBaseBranch) return taskBaseBranch; + + // 2. Try project settings mainBranch + if (project.settings?.mainBranch && GIT_BRANCH_REGEX.test(project.settings.mainBranch)) { + return project.settings.mainBranch; + } + + // 3. Use pre-detected project default branch + return projectDefaultBranch || 'main'; + }; + + // Async helper to process a single worktree entry + const processWorktreeEntryAsync = async (entry: string, entryPath: string): Promise => { try { - // Get branch info - const branch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { + // Get branch info (always needed) + const { stdout: branchOutput } = await execFileAsync(gitPath, ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: entryPath, encoding: 'utf-8' - }).trim(); + }); + const branch = branchOutput.trim(); - // Get base branch using proper fallback chain: - // 1. Task metadata baseBranch, 2. Project settings mainBranch, 3. main/master detection - // Note: We do NOT use current HEAD as that may be a feature branch - const baseBranch = getEffectiveBaseBranch(project.path, entry, project.settings?.mainBranch); + const baseBranch = getBaseBranchForEntry(entry); - // Get commit count (cross-platform - no shell syntax) - let commitCount = 0; - try { - const countOutput = execFileSync(getToolPath('git'), ['rev-list', '--count', `${baseBranch}..HEAD`], { - cwd: entryPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] - }).trim(); - commitCount = parseInt(countOutput, 10) || 0; - } catch { - commitCount = 0; - } - - // Get diff stats (cross-platform - no shell syntax) - let filesChanged = 0; - let additions = 0; - let deletions = 0; - let diffStat = ''; - - try { - diffStat = execFileSync(getToolPath('git'), ['diff', '--shortstat', `${baseBranch}...HEAD`], { - cwd: entryPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] - }).trim(); - - const filesMatch = diffStat.match(/(\d+) files? changed/); - const addMatch = diffStat.match(/(\d+) insertions?/); - const delMatch = diffStat.match(/(\d+) deletions?/); - - if (filesMatch) filesChanged = parseInt(filesMatch[1], 10) || 0; - if (addMatch) additions = parseInt(addMatch[1], 10) || 0; - if (delMatch) deletions = parseInt(delMatch[1], 10) || 0; - } catch { - // Ignore diff errors - } - - worktrees.push({ + const item: WorktreeListItem = { specName: entry, path: entryPath, branch, baseBranch, - commitCount, - filesChanged, - additions, - deletions - }); + }; + + // Only fetch stats when requested + if (includeStats) { + // Run commit count and diff stats in parallel + const [countResult, diffResult] = await Promise.allSettled([ + execFileAsync(gitPath, ['rev-list', '--count', `${baseBranch}..HEAD`], { + cwd: entryPath, + encoding: 'utf-8', + }), + execFileAsync(gitPath, ['diff', '--shortstat', `${baseBranch}...HEAD`], { + cwd: entryPath, + encoding: 'utf-8', + }), + ]); + + if (countResult.status === 'fulfilled') { + item.commitCount = parseInt(countResult.value.stdout.trim(), 10) || 0; + } else { + item.commitCount = 0; + } + + if (diffResult.status === 'fulfilled') { + const diffStat = diffResult.value.stdout.trim(); + const filesMatch = diffStat.match(/(\d+) files? changed/); + const addMatch = diffStat.match(/(\d+) insertions?/); + const delMatch = diffStat.match(/(\d+) deletions?/); + + item.filesChanged = filesMatch ? parseInt(filesMatch[1], 10) || 0 : 0; + item.additions = addMatch ? parseInt(addMatch[1], 10) || 0 : 0; + item.deletions = delMatch ? parseInt(delMatch[1], 10) || 0 : 0; + } else { + item.filesChanged = 0; + item.additions = 0; + item.deletions = 0; + } + } + + return item; } catch (gitError) { console.error(`Error getting info for worktree ${entry}:`, gitError); - // Skip this worktree if we can't get git info + return null; } }; - // Scan worktrees directory + // Scan worktrees directory and process all entries in parallel + let worktrees: WorktreeListItem[] = []; if (existsSync(worktreesDir)) { const entries = readdirSync(worktreesDir); + const dirEntries: Array<{ entry: string; entryPath: string }> = []; + for (const entry of entries) { const entryPath = path.join(worktreesDir, entry); try { const stat = statSync(entryPath); if (stat.isDirectory()) { - processWorktreeEntry(entry, entryPath); + dirEntries.push({ entry, entryPath }); } } catch { // Skip entries that can't be stat'd } } + + // Process all worktrees in parallel + const results = await Promise.allSettled( + dirEntries.map(({ entry, entryPath }) => processWorktreeEntryAsync(entry, entryPath)) + ); + + worktrees = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value) + .filter((item): item is WorktreeListItem => item !== null); } return { success: true, data: { worktrees } }; diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index 50fcefd5..290fa787 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -60,7 +60,7 @@ export interface TaskAPI { mergeWorktreePreview: (taskId: string) => Promise>; discardWorktree: (taskId: string, skipStatusChange?: boolean) => Promise>; clearStagedState: (taskId: string) => Promise>; - listWorktrees: (projectId: string) => Promise>; + listWorktrees: (projectId: string, options?: { includeStats?: boolean }) => Promise>; worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise>; worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise>; worktreeDetectTools: () => Promise; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>>; @@ -161,8 +161,8 @@ export const createTaskAPI = (): TaskAPI => ({ clearStagedState: (taskId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_CLEAR_STAGED_STATE, taskId), - listWorktrees: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId), + listWorktrees: (projectId: string, options?: { includeStats?: boolean }): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId, options), worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_IDE, worktreePath, ide, customPath), diff --git a/apps/frontend/src/renderer/components/Worktrees.tsx b/apps/frontend/src/renderer/components/Worktrees.tsx index cd629339..95d8fbb3 100644 --- a/apps/frontend/src/renderer/components/Worktrees.tsx +++ b/apps/frontend/src/renderer/components/Worktrees.tsx @@ -166,7 +166,7 @@ export function Worktrees({ projectId }: WorktreesProps) { try { // Fetch both task worktrees and terminal worktrees in parallel const [taskResult, terminalResult] = await Promise.all([ - window.electronAPI.listWorktrees(projectId), + window.electronAPI.listWorktrees(projectId, { includeStats: true }), window.electronAPI.listTerminalWorktrees(selectedProject.path) ]); @@ -285,10 +285,10 @@ export function Worktrees({ projectId }: WorktreesProps) { worktreePath: worktree.path, branch: worktree.branch, baseBranch: worktree.baseBranch, - commitCount: worktree.commitCount, - filesChanged: worktree.filesChanged, - additions: worktree.additions, - deletions: worktree.deletions + commitCount: worktree.commitCount ?? 0, + filesChanged: worktree.filesChanged ?? 0, + additions: worktree.additions ?? 0, + deletions: worktree.deletions ?? 0 }); // Open Create PR dialog @@ -586,19 +586,19 @@ export function Worktrees({ projectId }: WorktreesProps) {
- {worktree.filesChanged} files changed + {worktree.filesChanged ?? 0} files changed
- {worktree.commitCount} commits ahead + {worktree.commitCount ?? 0} commits ahead
- {worktree.additions} + {worktree.additions ?? 0}
- {worktree.deletions} + {worktree.deletions ?? 0}
@@ -790,7 +790,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
Changes - {selectedWorktree.commitCount} commits, {selectedWorktree.filesChanged} files + {selectedWorktree.commitCount ?? 0} commits, {selectedWorktree.filesChanged ?? 0} files
diff --git a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx index be35fa92..a3f83808 100644 --- a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx +++ b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx @@ -62,7 +62,7 @@ export function WorktreeSelector({ // Fetch terminal worktrees, task worktrees, and other worktrees in parallel const [terminalResult, taskResult, otherResult] = await Promise.all([ window.electronAPI.listTerminalWorktrees(projectPath), - project?.id ? window.electronAPI.listWorktrees(project.id) : Promise.resolve(null), + project?.id ? window.electronAPI.listWorktrees(project.id, { includeStats: false }) : Promise.resolve(null), window.electronAPI.listOtherWorktrees(projectPath), ]); diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 94914d94..afb35a38 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -182,7 +182,7 @@ export interface ElectronAPI { createWorktreePR: (taskId: string, options?: WorktreeCreatePROptions) => Promise>; discardWorktree: (taskId: string, skipStatusChange?: boolean) => Promise>; clearStagedState: (taskId: string) => Promise>; - listWorktrees: (projectId: string) => Promise>; + listWorktrees: (projectId: string, options?: { includeStats?: boolean }) => Promise>; worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise>; worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise>; worktreeDetectTools: () => Promise; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>>; diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index c78b0b7a..c2095489 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -442,10 +442,10 @@ export interface WorktreeListItem { path: string; branch: string; baseBranch: string; - commitCount: number; - filesChanged: number; - additions: number; - deletions: number; + commitCount?: number; + filesChanged?: number; + additions?: number; + deletions?: number; } /**