Fix worktree scope: approval tracking, cache, performance, empty state
- Track worktree approvals in review store since git staging is a no-op for committed-only files - Remove ineffective cache clearing on every GET request - Parallelize independent git calls in getWorktreeStatus via Promise.all - Add upstream tracking branch as fallback for base branch detection - Use scope-aware loadFiles() in approve/reset instead of raw POST response - Show "No unstaged changes" instead of "Select a file" for files without diffs in Needs Review mode - Replace remaining old await-new-Promise patterns with getStoreValue
5 files changed,  +98, -69
M src/lib/server/git.ts
+37, -23
  1@@ -1,5 +1,6 @@
  2 import simpleGit, { type SimpleGit } from 'simple-git';
  3 import type { FileChange, DiffFile, DiffHunk, DiffLine, DiffScope } from '$lib/types/index.ts';
  4+import { getWorktreeApprovals } from './review-store.ts';
  5 
  6 let git: SimpleGit;
  7 
  8@@ -458,10 +459,6 @@ async function getUnstagedDiff(filePath?: string): Promise<string> {
  9 
 10 let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
 11 
 12-export function clearBaseBranchCache(): void {
 13-	cachedBaseBranchInfo = null;
 14-}
 15-
 16 export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> {
 17 	if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
 18 
 19@@ -504,6 +501,22 @@ export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null;
 20 		}
 21 	}
 22 
 23+	// Fallback: try upstream tracking branch
 24+	if (!bestBranch) {
 25+		try {
 26+			const upstream = (await g.raw(['rev-parse', '--abbrev-ref', '@{upstream}'])).trim();
 27+			// upstream is e.g. "origin/main" — extract the local branch name
 28+			const localName = upstream.replace(/^[^/]+\//, '');
 29+			if (localName !== currentBranch) {
 30+				const mergeBase = (await g.raw(['merge-base', 'HEAD', upstream])).trim();
 31+				bestBranch = localName;
 32+				bestMergeBase = mergeBase;
 33+			}
 34+		} catch {
 35+			// No upstream configured — skip
 36+		}
 37+	}
 38+
 39 	cachedBaseBranchInfo = { baseBranch: bestBranch, mergeBase: bestMergeBase };
 40 	return cachedBaseBranchInfo;
 41 }
 42@@ -550,11 +563,17 @@ export async function getWorktreeStatus(): Promise<FileChange[]> {
 43 	const { mergeBase } = await getBaseBranchInfo();
 44 	if (!mergeBase) return getStatus(); // Fallback
 45 
 46+	// Run all independent git calls in parallel
 47+	const [nameStatus, gitStatus, diffStat] = await Promise.all([
 48+		g.diff(['--name-status', mergeBase]),
 49+		g.status(),
 50+		g.diff([mergeBase, '--numstat']).catch(() => '')
 51+	]);
 52+
 53 	const files: FileChange[] = [];
 54 	const seen = new Set<string>();
 55 
 56-	// Get all files changed between merge-base and working tree
 57-	const nameStatus = await g.diff(['--name-status', mergeBase]);
 58+	// Parse files changed between merge-base and working tree
 59 	for (const line of nameStatus.split('\n')) {
 60 		if (!line.trim()) continue;
 61 		const match = line.match(/^([AMDRC])\d*\t(.+?)(?:\t(.+))?$/);
 62@@ -577,38 +596,33 @@ export async function getWorktreeStatus(): Promise<FileChange[]> {
 63 	}
 64 
 65 	// Include untracked files
 66-	const gitStatus = await g.status();
 67 	for (const f of gitStatus.not_added) {
 68 		if (seen.has(f)) continue;
 69 		seen.add(f);
 70 		files.push({ path: f, status: 'added', staged: false, approved: false, additions: 0, deletions: 0 });
 71 	}
 72 
 73-	// Merge staging info from git status
 74+	// Merge staging info from git status + worktree approvals
 75 	const stagedSet = new Set([...gitStatus.created, ...gitStatus.staged]);
 76+	const approvals = getWorktreeApprovals();
 77 	for (const file of files) {
 78-		if (stagedSet.has(file.path)) {
 79+		if (stagedSet.has(file.path) || approvals.has(file.path)) {
 80 			file.staged = true;
 81 			file.approved = true;
 82 		}
 83 	}
 84 
 85-	// Get numstat for additions/deletions
 86-	try {
 87-		const diffStat = await g.diff([mergeBase, '--numstat']);
 88-		for (const line of diffStat.split('\n')) {
 89-			const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
 90-			if (match) {
 91-				const [, adds, dels, path] = match;
 92-				const file = files.find((f) => f.path === path);
 93-				if (file) {
 94-					file.additions = adds === '-' ? 0 : parseInt(adds, 10);
 95-					file.deletions = dels === '-' ? 0 : parseInt(dels, 10);
 96-				}
 97+	// Apply numstat additions/deletions
 98+	for (const line of diffStat.split('\n')) {
 99+		const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
100+		if (match) {
101+			const [, adds, dels, path] = match;
102+			const file = files.find((f) => f.path === path);
103+			if (file) {
104+				file.additions = adds === '-' ? 0 : parseInt(adds, 10);
105+				file.deletions = dels === '-' ? 0 : parseInt(dels, 10);
106 			}
107 		}
108-	} catch {
109-		// numstat may fail
110 	}
111 
112 	return files;
M src/lib/server/review-store.ts
+14, -0
 1@@ -5,6 +5,7 @@ const threads = new Map<string, ReviewThread>();
 2 let reviewStatus: 'pending' | 'approved' | 'changes_requested' = 'pending';
 3 let reviewSummary: string | undefined;
 4 const contextFiles = new Set<string>();
 5+const worktreeApprovals = new Set<string>();
 6 
 7 export function addContextFile(filePath: string): void {
 8 	contextFiles.add(filePath);
 9@@ -110,11 +111,24 @@ export function setReviewSummary(summary: string): void {
10 	reviewSummary = summary;
11 }
12 
13+export function approveWorktreeFile(filePath: string): void {
14+	worktreeApprovals.add(filePath);
15+}
16+
17+export function unapproveWorktreeFile(filePath: string): void {
18+	worktreeApprovals.delete(filePath);
19+}
20+
21+export function getWorktreeApprovals(): Set<string> {
22+	return worktreeApprovals;
23+}
24+
25 export function clearAll(): void {
26 	threads.clear();
27 	reviewStatus = 'pending';
28 	reviewSummary = undefined;
29 	contextFiles.clear();
30+	worktreeApprovals.clear();
31 }
32 
33 export function markOutdated(threadId: string, outdated: boolean): boolean {
M src/lib/stores/files.ts
+21, -38
 1@@ -123,71 +123,54 @@ export async function setDiffScope(scope: DiffScope) {
 2 }
 3 
 4 export async function approveFile(path: string) {
 5-	const res = await fetch('/api/files', {
 6+	const scope = getStoreValue(diffScope);
 7+	await fetch('/api/files', {
 8 		method: 'POST',
 9 		headers: { 'Content-Type': 'application/json' },
10-		body: JSON.stringify({ path, action: 'approve' })
11+		body: JSON.stringify({ path, action: 'approve', scope })
12 	});
13-	const data = await res.json();
14-	if (Array.isArray(data)) {
15-		files.set(data);
16-		// Reload diffs since staging changed
17-		await loadAllDiffs();
18-	}
19+	await loadFiles();
20+	await loadAllDiffs();
21 }
22 
23 export async function unapproveFile(path: string) {
24-	const res = await fetch('/api/files', {
25+	const scope = getStoreValue(diffScope);
26+	await fetch('/api/files', {
27 		method: 'POST',
28 		headers: { 'Content-Type': 'application/json' },
29-		body: JSON.stringify({ path, action: 'unapprove' })
30+		body: JSON.stringify({ path, action: 'unapprove', scope })
31 	});
32-	const data = await res.json();
33-	if (Array.isArray(data)) {
34-		files.set(data);
35-		await loadAllDiffs();
36-	}
37+	await loadFiles();
38+	await loadAllDiffs();
39 }
40 
41 export async function resetFile(path: string) {
42-	const res = await fetch('/api/files', {
43+	await fetch('/api/files', {
44 		method: 'POST',
45 		headers: { 'Content-Type': 'application/json' },
46 		body: JSON.stringify({ path, action: 'reset' })
47 	});
48-	const data = await res.json();
49-	if (Array.isArray(data)) {
50-		files.set(data);
51-		await loadAllDiffs();
52-		// If the reset file was selected and is now gone, select another
53-		const currentSelected = await new Promise<string | null>((resolve) => {
54-			selectedFile.subscribe((v) => resolve(v))();
55-		});
56-		if (currentSelected === path) {
57-			const remaining = data as FileChange[];
58-			selectedFile.set(remaining.length > 0 ? remaining[0].path : null);
59-		}
60+	await loadFiles();
61+	await loadAllDiffs();
62+	// If the reset file was selected and is now gone, select another
63+	if (getStoreValue(selectedFile) === path) {
64+		const remaining = getStoreValue(files);
65+		selectedFile.set(remaining.length > 0 ? remaining[0].path : null);
66 	}
67 }
68 
69 export async function resetHunk(path: string, hunkHeader: string) {
70-	const res = await fetch('/api/files', {
71+	await fetch('/api/files', {
72 		method: 'POST',
73 		headers: { 'Content-Type': 'application/json' },
74 		body: JSON.stringify({ path, action: 'reset-hunk', hunkHeader })
75 	});
76-	const data = await res.json();
77-	if (Array.isArray(data)) {
78-		files.set(data);
79-		await loadAllDiffs();
80-	}
81+	await loadFiles();
82+	await loadAllDiffs();
83 }
84 
85 export async function toggleApproval(path: string) {
86-	const currentFiles = await new Promise<FileChange[]>((resolve) => {
87-		files.subscribe((v) => resolve(v))();
88-	});
89-	const file = currentFiles.find((f) => f.path === path);
90+	const file = getStoreValue(files).find((f) => f.path === path);
91 	if (file?.approved) {
92 		await unapproveFile(path);
93 	} else {
M src/routes/+page.svelte
+12, -1
 1@@ -6,7 +6,7 @@
 2 	import CommitDialog from '$lib/components/CommitDialog.svelte';
 3 	import ExportDialog from '$lib/components/ExportDialog.svelte';
 4 	import TerminalPanel from '$lib/components/TerminalPanel.svelte';
 5-	import { files, selectedFile, selectedDiff, selectedFileData, loadFiles, loadAllDiffs, toggleApproval, diffScope } from '$lib/stores/files.ts';
 6+	import { files, selectedFile, selectedDiff, selectedFileData, loadFiles, loadAllDiffs, toggleApproval, diffScope, diffMode } from '$lib/stores/files.ts';
 7 	import { loadComments } from '$lib/stores/review.ts';
 8 	import { sidebarOpen } from '$lib/stores/ui.ts';
 9 	import { toggleTerminal } from '$lib/stores/terminal.ts';
10@@ -151,6 +151,17 @@
11 					{/if}
12 				</div>
13 				<DiffView file={$selectedDiff} />
14+			{:else if $selectedFile}
15+				<div class="flex items-center justify-center h-full text-muted">
16+					<div class="text-center">
17+						<p class="font-mono text-sm text-secondary mb-2">{$selectedFile}</p>
18+						{#if $diffMode === 'unstaged'}
19+							<p class="text-sm">No unstaged changes in this file</p>
20+						{:else}
21+							<p class="text-sm">No diff available for this file</p>
22+						{/if}
23+					</div>
24+				</div>
25 			{:else}
26 				<div class="flex items-center justify-center h-full text-muted">
27 					<p class="text-sm">Select a file from the sidebar</p>
M src/routes/api/files/+server.ts
+14, -7
 1@@ -1,5 +1,6 @@
 2 import { json } from '@sveltejs/kit';
 3-import { getStatus, getWorktreeStatus, getBaseBranchInfo, stageFile, unstageFile, resetFile, resetHunk, clearBaseBranchCache } from '$lib/server/git.ts';
 4+import { getStatus, getWorktreeStatus, getBaseBranchInfo, stageFile, unstageFile, resetFile, resetHunk } from '$lib/server/git.ts';
 5+import { approveWorktreeFile, unapproveWorktreeFile } from '$lib/server/review-store.ts';
 6 import type { DiffScope } from '$lib/types/index.ts';
 7 import type { RequestHandler } from './$types.ts';
 8 
 9@@ -8,11 +9,9 @@ export const GET: RequestHandler = async ({ url }) => {
10 		const scope = (url.searchParams.get('scope') || 'uncommitted') as DiffScope;
11 
12 		if (scope === 'worktree') {
13-			clearBaseBranchCache();
14-			const [files, branchInfo] = await Promise.all([
15-				getWorktreeStatus(),
16-				getBaseBranchInfo()
17-			]);
18+			// getWorktreeStatus internally calls getBaseBranchInfo and populates the cache
19+			const files = await getWorktreeStatus();
20+			const branchInfo = await getBaseBranchInfo();
21 			return json({ files, baseBranch: branchInfo.baseBranch, mergeBase: branchInfo.mergeBase });
22 		}
23 
24@@ -26,15 +25,23 @@ export const GET: RequestHandler = async ({ url }) => {
25 export const POST: RequestHandler = async ({ request }) => {
26 	try {
27 		const body = await request.json();
28-		const { path, action } = body;
29+		const { path, action, scope } = body;
30 
31 		if (!path || !action) {
32 			return json({ error: 'Missing path or action' }, { status: 400 });
33 		}
34 
35+		const isWorktree = scope === 'worktree';
36+
37 		if (action === 'approve') {
38+			if (isWorktree) {
39+				approveWorktreeFile(path);
40+			}
41 			await stageFile(path);
42 		} else if (action === 'unapprove') {
43+			if (isWorktree) {
44+				unapproveWorktreeFile(path);
45+			}
46 			await unstageFile(path);
47 		} else if (action === 'reset') {
48 			await resetFile(path);