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 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-27 14:37:16 +01:00
committed by GitHub
parent 83a64b88e7
commit 399a7e736a
7 changed files with 126 additions and 78 deletions
+2 -2
View File
@@ -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;
@@ -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<IPCResult<WorktreeListResult>> => {
async (_, projectId: string, options?: { includeStats?: boolean }): Promise<IPCResult<WorktreeListResult>> => {
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<WorktreeListItem | null> => {
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<WorktreeListItem | null> => r.status === 'fulfilled')
.map(r => r.value)
.filter((item): item is WorktreeListItem => item !== null);
}
return { success: true, data: { worktrees } };
+3 -3
View File
@@ -60,7 +60,7 @@ export interface TaskAPI {
mergeWorktreePreview: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeMergeResult>>;
discardWorktree: (taskId: string, skipStatusChange?: boolean) => Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>>;
clearStagedState: (taskId: string) => Promise<IPCResult<{ cleared: boolean }>>;
listWorktrees: (projectId: string) => Promise<IPCResult<import('../../shared/types').WorktreeListResult>>;
listWorktrees: (projectId: string, options?: { includeStats?: boolean }) => Promise<IPCResult<import('../../shared/types').WorktreeListResult>>;
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
worktreeDetectTools: () => Promise<IPCResult<{ ides: Array<{ id: string; name: string; path: string; installed: boolean }>; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>>;
@@ -161,8 +161,8 @@ export const createTaskAPI = (): TaskAPI => ({
clearStagedState: (taskId: string): Promise<IPCResult<{ cleared: boolean }>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_CLEAR_STAGED_STATE, taskId),
listWorktrees: (projectId: string): Promise<IPCResult<import('../../shared/types').WorktreeListResult>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId),
listWorktrees: (projectId: string, options?: { includeStats?: boolean }): Promise<IPCResult<import('../../shared/types').WorktreeListResult>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId, options),
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string): Promise<IPCResult<{ opened: boolean }>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_IDE, worktreePath, ide, customPath),
@@ -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) {
<div className="flex flex-wrap gap-4 text-sm mb-4">
<div className="flex items-center gap-1.5 text-muted-foreground">
<FileCode className="h-3.5 w-3.5" />
<span>{worktree.filesChanged} files changed</span>
<span>{worktree.filesChanged ?? 0} files changed</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ChevronRight className="h-3.5 w-3.5" />
<span>{worktree.commitCount} commits ahead</span>
<span>{worktree.commitCount ?? 0} commits ahead</span>
</div>
<div className="flex items-center gap-1.5 text-success">
<Plus className="h-3.5 w-3.5" />
<span>{worktree.additions}</span>
<span>{worktree.additions ?? 0}</span>
</div>
<div className="flex items-center gap-1.5 text-destructive">
<Minus className="h-3.5 w-3.5" />
<span>{worktree.deletions}</span>
<span>{worktree.deletions ?? 0}</span>
</div>
</div>
@@ -790,7 +790,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Changes</span>
<span>
{selectedWorktree.commitCount} commits, {selectedWorktree.filesChanged} files
{selectedWorktree.commitCount ?? 0} commits, {selectedWorktree.filesChanged ?? 0} files
</span>
</div>
</div>
@@ -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),
]);
+1 -1
View File
@@ -182,7 +182,7 @@ export interface ElectronAPI {
createWorktreePR: (taskId: string, options?: WorktreeCreatePROptions) => Promise<IPCResult<WorktreeCreatePRResult>>;
discardWorktree: (taskId: string, skipStatusChange?: boolean) => Promise<IPCResult<WorktreeDiscardResult>>;
clearStagedState: (taskId: string) => Promise<IPCResult<{ cleared: boolean }>>;
listWorktrees: (projectId: string) => Promise<IPCResult<WorktreeListResult>>;
listWorktrees: (projectId: string, options?: { includeStats?: boolean }) => Promise<IPCResult<WorktreeListResult>>;
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
worktreeDetectTools: () => Promise<IPCResult<{ ides: Array<{ id: string; name: string; path: string; installed: boolean }>; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>>;
+4 -4
View File
@@ -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;
}
/**