git.ts
1import simpleGit, { type SimpleGit } from 'simple-git';
2import type { ChangeStack, FileChange, DiffMode, DiffScope } from '$lib/types/index';
3import { getWorktreeApprovals, approveWorktreeFile, unapproveWorktreeFile } from './review-store.ts';
4import { getRepoDir } from './repo.ts';
5
6let git: SimpleGit;
7
8export function getGit(): SimpleGit {
9 if (!git) {
10 const repoDir = getRepoDir();
11 git = simpleGit(repoDir);
12 }
13 return git;
14}
15
16export async function getStatus(): Promise<FileChange[]> {
17 const g = getGit();
18 const status = await g.status();
19 const files: FileChange[] = [];
20 const seen = new Set<string>();
21
22 // Helper to add a file only once
23 function addFile(path: string, fileStatus: FileChange['status'], staged: boolean, oldPath?: string) {
24 if (seen.has(path)) return;
25 seen.add(path);
26 files.push({ path, oldPath, status: fileStatus, staged, approved: staged, additions: 0, deletions: 0 });
27 }
28
29 // Staged new files
30 for (const f of status.created) {
31 addFile(f, 'added', true);
32 }
33 // Untracked files (not staged)
34 for (const f of status.not_added) {
35 addFile(f, 'added', false);
36 }
37 for (const f of status.modified) {
38 addFile(f, 'modified', false);
39 }
40 for (const f of status.deleted) {
41 addFile(f, 'deleted', false);
42 }
43 for (const f of status.renamed) {
44 addFile(f.to, 'renamed', false, f.from);
45 }
46 // Mark staged files as both staged and approved
47 for (const f of status.staged) {
48 const existing = files.find((x) => x.path === f);
49 if (existing) {
50 existing.staged = true;
51 existing.approved = true;
52 }
53 }
54
55 // Get numstat for additions/deletions
56 try {
57 const hasCommits = await hasHead();
58 if (hasCommits) {
59 const diffStat = await g.diff(['HEAD', '--numstat']);
60 for (const line of diffStat.split('\n')) {
61 const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
62 if (match) {
63 const [, adds, dels, path] = match;
64 const file = files.find((f) => f.path === path);
65 if (file) {
66 file.additions = adds === '-' ? 0 : parseInt(adds, 10);
67 file.deletions = dels === '-' ? 0 : parseInt(dels, 10);
68 }
69 }
70 }
71 }
72 } catch {
73 // numstat may fail for initial commits
74 }
75
76 return files;
77}
78
79async function hasHead(): Promise<boolean> {
80 const g = getGit();
81 try {
82 await g.revparse(['HEAD']);
83 return true;
84 } catch {
85 return false;
86 }
87}
88
89export async function getDiff(filePath?: string): Promise<string> {
90 const g = getGit();
91 const hasCommits = await hasHead();
92 let result = '';
93
94 if (hasCommits) {
95 // Show all changes (staged + unstaged) relative to HEAD
96 const args = ['HEAD'];
97 if (filePath) args.push('--', filePath);
98 try {
99 result = await g.diff(args);
100 } catch {
101 result = '';
102 }
103 }
104
105 // Also get untracked files as diffs
106 const status = await g.status();
107 const untrackedFiles = filePath
108 ? status.not_added.filter((f) => f === filePath)
109 : status.not_added;
110
111 for (const f of untrackedFiles) {
112 try {
113 const { readFile } = await import('fs/promises');
114 const { join } = await import('path');
115 const content = await readFile(join(getRepoDir(), f), 'utf-8');
116 const lines = content.split('\n');
117 // Build a synthetic diff for untracked files
118 const header = `diff --git a/${f} b/${f}\nnew file mode 100644\n--- /dev/null\n+++ b/${f}\n@@ -0,0 +1,${lines.length} @@\n`;
119 const body = lines.map((l) => '+' + l).join('\n');
120 result += '\n' + header + body + '\n';
121 } catch {
122 // Skip files that can't be read (binary, etc.)
123 }
124 }
125
126 // For repos with no commits, also get staged files
127 if (!hasCommits) {
128 const stagedFiles = filePath
129 ? status.created.filter((f) => f === filePath)
130 : status.created;
131
132 for (const f of stagedFiles) {
133 try {
134 const { readFile } = await import('fs/promises');
135 const { join } = await import('path');
136 const content = await readFile(join(getRepoDir(), f), 'utf-8');
137 const lines = content.split('\n');
138 const header = `diff --git a/${f} b/${f}\nnew file mode 100644\n--- /dev/null\n+++ b/${f}\n@@ -0,0 +1,${lines.length} @@\n`;
139 const body = lines.map((l) => '+' + l).join('\n');
140 result += '\n' + header + body + '\n';
141 } catch {
142 // Skip files that can't be read
143 }
144 }
145 }
146
147 return result;
148}
149
150export async function getFileContent(filePath: string, ref: string = 'HEAD'): Promise<string> {
151 const g = getGit();
152 try {
153 return await g.show([`${ref}:${filePath}`]);
154 } catch {
155 return '';
156 }
157}
158
159export async function stageFile(filePath: string): Promise<void> {
160 const g = getGit();
161 await g.add(filePath);
162}
163
164export async function unstageFile(filePath: string): Promise<void> {
165 const g = getGit();
166 const hasCommits = await hasHead();
167 if (hasCommits) {
168 await g.raw(['reset', 'HEAD', '--', filePath]);
169 } else {
170 await g.raw(['rm', '--cached', filePath]);
171 }
172}
173
174export async function stageAll(): Promise<void> {
175 const g = getGit();
176 await g.add('-A');
177}
178
179export async function resetFile(filePath: string): Promise<void> {
180 const g = getGit();
181 const status = await g.status();
182
183 // Check if it's an untracked/new file
184 if (status.not_added.includes(filePath) || status.created.includes(filePath)) {
185 // Remove the file
186 const { unlink } = await import('fs/promises');
187 const { join } = await import('path');
188 await unlink(join(getRepoDir(), filePath));
189 // If it was staged, also remove from index
190 if (status.created.includes(filePath)) {
191 await g.raw(['rm', '--cached', filePath]);
192 }
193 } else {
194 // Modified or deleted file: restore to HEAD version
195 await g.checkout(['HEAD', '--', filePath]);
196 }
197}
198
199export async function resetHunk(filePath: string, hunkHeader: string): Promise<void> {
200 const g = getGit();
201 // Get the full diff for this file, then construct a reverse patch for just this hunk
202 const diff = await g.diff(['HEAD', '--', filePath]);
203 const hunks = diff.split(/(?=^@@)/m);
204 const fileHeader = hunks[0] || '';
205
206 // Find the matching hunk
207 const targetHunk = hunks.find((h) => h.startsWith(hunkHeader));
208 if (!targetHunk) return;
209
210 // Build a patch with just this hunk and apply in reverse
211 const patch = fileHeader + targetHunk;
212 const { writeFile, unlink } = await import('fs/promises');
213 const { join } = await import('path');
214 const { tmpdir } = await import('os');
215 const patchFile = join(tmpdir(), `lcr-patch-${Date.now()}.patch`);
216 await writeFile(patchFile, patch);
217 try {
218 await g.raw(['apply', '--reverse', patchFile]);
219 } finally {
220 await unlink(patchFile).catch(() => {});
221 }
222}
223
224export async function commitStaged(message: string): Promise<string> {
225 const g = getGit();
226 const result = await g.commit(message);
227 return result.commit;
228}
229
230export async function commitAll(message: string): Promise<string> {
231 const g = getGit();
232 await g.add('-A');
233 const result = await g.commit(message);
234 return result.commit;
235}
236
237export async function commit(message: string): Promise<string> {
238 const g = getGit();
239 const result = await g.commit(message);
240 return result.commit;
241}
242
243export async function getDiffByMode(mode: DiffMode, filePath?: string, scope?: DiffScope): Promise<string> {
244 if (mode === 'unstaged') {
245 return getUnstagedDiff(filePath);
246 }
247 if (scope === 'worktree') {
248 return getWorktreeDiff(filePath);
249 }
250 return getDiff(filePath);
251}
252
253async function getUnstagedDiff(filePath?: string): Promise<string> {
254 const g = getGit();
255 // git diff (no args) shows working tree vs index (unstaged changes only)
256 const args: string[] = [];
257 if (filePath) args.push('--', filePath);
258 try {
259 let result = await g.diff(args);
260
261 // Also include untracked files (they are always "unstaged")
262 const status = await g.status();
263 const untrackedFiles = filePath
264 ? status.not_added.filter((f) => f === filePath)
265 : status.not_added;
266
267 for (const f of untrackedFiles) {
268 try {
269 const { readFile } = await import('fs/promises');
270 const { join } = await import('path');
271 const content = await readFile(join(getRepoDir(), f), 'utf-8');
272 const lines = content.split('\n');
273 const header = `diff --git a/${f} b/${f}\nnew file mode 100644\n--- /dev/null\n+++ b/${f}\n@@ -0,0 +1,${lines.length} @@\n`;
274 const body = lines.map((l) => '+' + l).join('\n');
275 result += '\n' + header + body + '\n';
276 } catch {
277 // Skip binary files
278 }
279 }
280
281 return result;
282 } catch {
283 return '';
284 }
285}
286
287// --- Worktree (branch) diff support ---
288
289let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
290
291export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> {
292 if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
293
294 const g = getGit();
295 const currentBranch = (await g.revparse(['--abbrev-ref', 'HEAD'])).trim();
296
297 // Detached HEAD — can't determine base branch
298 if (currentBranch === 'HEAD') {
299 cachedBaseBranchInfo = { baseBranch: null, mergeBase: null };
300 return cachedBaseBranchInfo;
301 }
302
303 const candidates = ['main', 'master', 'development', 'develop'];
304 let bestBranch: string | null = null;
305 let bestMergeBase: string | null = null;
306 let bestTimestamp = 0;
307
308 for (const branch of candidates) {
309 try {
310 // Check if branch exists locally
311 await g.raw(['rev-parse', '--verify', branch]);
312
313 // Skip if we're on this branch
314 if (branch === currentBranch) continue;
315
316 const mergeBase = (await g.raw(['merge-base', 'HEAD', branch])).trim();
317 // Get the timestamp of the merge-base to pick the most recent one
318 const timestamp = parseInt(
319 (await g.raw(['log', '-1', '--format=%ct', mergeBase])).trim(),
320 10
321 );
322
323 if (timestamp > bestTimestamp) {
324 bestTimestamp = timestamp;
325 bestBranch = branch;
326 bestMergeBase = mergeBase;
327 }
328 } catch {
329 // Branch doesn't exist or merge-base failed — skip
330 }
331 }
332
333 // Fallback: try upstream tracking branch
334 if (!bestBranch) {
335 try {
336 const upstream = (await g.raw(['rev-parse', '--abbrev-ref', '@{upstream}'])).trim();
337 // upstream is e.g. "origin/main" — extract the local branch name
338 const localName = upstream.replace(/^[^/]+\//, '');
339 if (localName !== currentBranch) {
340 const mergeBase = (await g.raw(['merge-base', 'HEAD', upstream])).trim();
341 bestBranch = localName;
342 bestMergeBase = mergeBase;
343 }
344 } catch {
345 // No upstream configured — skip
346 }
347 }
348
349 cachedBaseBranchInfo = { baseBranch: bestBranch, mergeBase: bestMergeBase };
350 return cachedBaseBranchInfo;
351}
352
353async function getWorktreeDiff(filePath?: string): Promise<string> {
354 const g = getGit();
355 const { mergeBase } = await getBaseBranchInfo();
356 if (!mergeBase) return getDiff(filePath); // Fallback to regular diff
357
358 const args = [mergeBase];
359 if (filePath) args.push('--', filePath);
360 let result = '';
361 try {
362 result = await g.diff(args);
363 } catch {
364 result = '';
365 }
366
367 // Also include untracked files (same as getDiff)
368 const status = await g.status();
369 const untrackedFiles = filePath
370 ? status.not_added.filter((f) => f === filePath)
371 : status.not_added;
372
373 for (const f of untrackedFiles) {
374 try {
375 const { readFile } = await import('fs/promises');
376 const { join } = await import('path');
377 const content = await readFile(join(getRepoDir(), f), 'utf-8');
378 const lines = content.split('\n');
379 const header = `diff --git a/${f} b/${f}\nnew file mode 100644\n--- /dev/null\n+++ b/${f}\n@@ -0,0 +1,${lines.length} @@\n`;
380 const body = lines.map((l) => '+' + l).join('\n');
381 result += '\n' + header + body + '\n';
382 } catch {
383 // Skip files that can't be read
384 }
385 }
386
387 return result;
388}
389
390export async function getWorktreeStatus(): Promise<FileChange[]> {
391 const g = getGit();
392 const { mergeBase } = await getBaseBranchInfo();
393 if (!mergeBase) return getStatus(); // Fallback
394
395 // Run all independent git calls in parallel
396 const [nameStatus, gitStatus, diffStat] = await Promise.all([
397 g.diff(['--name-status', mergeBase]),
398 g.status(),
399 g.diff([mergeBase, '--numstat']).catch(() => '')
400 ]);
401
402 const files: FileChange[] = [];
403 const seen = new Set<string>();
404
405 // Parse files changed between merge-base and working tree
406 for (const line of nameStatus.split('\n')) {
407 if (!line.trim()) continue;
408 const match = line.match(/^([AMDRC])\d*\t(.+?)(?:\t(.+))?$/);
409 if (!match) continue;
410 const [, statusChar, filePath, newPath] = match;
411 const path = newPath || filePath;
412 if (seen.has(path)) continue;
413 seen.add(path);
414
415 let status: FileChange['status'] = 'modified';
416 let oldPath: string | undefined;
417 if (statusChar === 'A') status = 'added';
418 else if (statusChar === 'D') status = 'deleted';
419 else if (statusChar === 'R' || statusChar === 'C') {
420 status = 'renamed';
421 oldPath = filePath;
422 }
423
424 files.push({ path, oldPath, status, staged: false, approved: false, additions: 0, deletions: 0 });
425 }
426
427 // Include untracked files
428 for (const f of gitStatus.not_added) {
429 if (seen.has(f)) continue;
430 seen.add(f);
431 files.push({ path: f, status: 'added', staged: false, approved: false, additions: 0, deletions: 0 });
432 }
433
434 // Merge staging info from git status + worktree approvals
435 const stagedSet = new Set([...gitStatus.created, ...gitStatus.staged]);
436 const approvals = getWorktreeApprovals();
437 for (const file of files) {
438 if (stagedSet.has(file.path) || approvals.has(file.path)) {
439 file.staged = true;
440 file.approved = true;
441 }
442 }
443
444 // Apply numstat additions/deletions
445 for (const line of diffStat.split('\n')) {
446 const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
447 if (match) {
448 const [, adds, dels, path] = match;
449 const file = files.find((f) => f.path === path);
450 if (file) {
451 file.additions = adds === '-' ? 0 : parseInt(adds, 10);
452 file.deletions = dels === '-' ? 0 : parseInt(dels, 10);
453 }
454 }
455 }
456
457 return files;
458}
459
460export async function getRepoInfo(): Promise<{ branch: string; repoName: string }> {
461 const g = getGit();
462 const branch = await g.revparse(['--abbrev-ref', 'HEAD']);
463 const repoDir = getRepoDir();
464 const repoName = repoDir.split('/').pop() || 'unknown';
465 return { branch: branch.trim(), repoName };
466}
467
468// --- Unified VCS interface helpers (git implementation) ---
469
470export async function approveFile(filePath: string, scope?: DiffScope): Promise<void> {
471 if (scope === 'worktree') {
472 approveWorktreeFile(filePath);
473 } else {
474 await stageFile(filePath);
475 }
476}
477
478export async function unapproveFile(filePath: string, scope?: DiffScope): Promise<void> {
479 if (scope === 'worktree') {
480 unapproveWorktreeFile(filePath);
481 } else {
482 await unstageFile(filePath);
483 }
484}
485
486export async function getOldRef(scope: DiffScope = 'uncommitted'): Promise<string> {
487 if (scope === 'worktree') {
488 const { mergeBase } = await getBaseBranchInfo();
489 if (mergeBase) return mergeBase;
490 }
491 return 'HEAD';
492}
493
494// Stacked-change navigation is a jj-only feature.
495export async function getNewRef(): Promise<string | null> {
496 return null;
497}
498
499export async function getChangeStack(): Promise<ChangeStack> {
500 return { main: null, current: null, selected: null, workingCopy: null, changes: [] };
501}
502
503export async function selectChange(_changeId: string): Promise<ChangeStack> {
504 throw new Error('Selecting changes is only supported in jj repositories.');
505}
506
507export async function approveChange(_changeId?: string): Promise<ChangeStack> {
508 throw new Error('Approving changes is only supported in jj repositories.');
509}