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:
@@ -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 } };
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 }> }>>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user