5 files changed,
+98,
-69
+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;
+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 {
+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 {
+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>
+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);