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}