Something went wrong. Try again.
Github-style local code review for working in agents in jujutsu repos
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510import simpleGit, { type SimpleGit } from 'simple-git';import type { ChangeStack, FileChange, DiffMode, DiffScope } from '$lib/types/index';import { getWorktreeApprovals, approveWorktreeFile, unapproveWorktreeFile } from './review-store.ts';import { getRepoDir } from './repo.ts';
let git: SimpleGit;
export function getGit(): SimpleGit { if (!git) { const repoDir = getRepoDir(); git = simpleGit(repoDir); } return git;}
export async function getStatus(): Promise<FileChange[]> { const g = getGit(); const status = await g.status(); const files: FileChange[] = []; const seen = new Set<string>();
// Helper to add a file only once function addFile(path: string, fileStatus: FileChange['status'], staged: boolean, oldPath?: string) { if (seen.has(path)) return; seen.add(path); files.push({ path, oldPath, status: fileStatus, staged, approved: staged, additions: 0, deletions: 0 }); }
// Staged new files for (const f of status.created) { addFile(f, 'added', true); } // Untracked files (not staged) for (const f of status.not_added) { addFile(f, 'added', false); } for (const f of status.modified) { addFile(f, 'modified', false); } for (const f of status.deleted) { addFile(f, 'deleted', false); } for (const f of status.renamed) { addFile(f.to, 'renamed', false, f.from); } // Mark staged files as both staged and approved for (const f of status.staged) { const existing = files.find((x) => x.path === f); if (existing) { existing.staged = true; existing.approved = true; } }
// Get numstat for additions/deletions try { const hasCommits = await hasHead(); if (hasCommits) { const diffStat = await g.diff(['HEAD', '--numstat']); for (const line of diffStat.split('\n')) { const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/); if (match) { const [, adds, dels, path] = match; const file = files.find((f) => f.path === path); if (file) { file.additions = adds === '-' ? 0 : parseInt(adds, 10); file.deletions = dels === '-' ? 0 : parseInt(dels, 10); } } } } } catch { // numstat may fail for initial commits }
return files;}
async function hasHead(): Promise<boolean> { const g = getGit(); try { await g.revparse(['HEAD']); return true; } catch { return false; }}
export async function getDiff(filePath?: string): Promise<string> { const g = getGit(); const hasCommits = await hasHead(); let result = '';
if (hasCommits) { // Show all changes (staged + unstaged) relative to HEAD const args = ['HEAD']; if (filePath) args.push('--', filePath); try { result = await g.diff(args); } catch { result = ''; } }
// Also get untracked files as diffs const status = await g.status(); const untrackedFiles = filePath ? status.not_added.filter((f) => f === filePath) : status.not_added;
for (const f of untrackedFiles) { try { const { readFile } = await import('fs/promises'); const { join } = await import('path'); const content = await readFile(join(getRepoDir(), f), 'utf-8'); const lines = content.split('\n'); // Build a synthetic diff for untracked files 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`; const body = lines.map((l) => '+' + l).join('\n'); result += '\n' + header + body + '\n'; } catch { // Skip files that can't be read (binary, etc.) } }
// For repos with no commits, also get staged files if (!hasCommits) { const stagedFiles = filePath ? status.created.filter((f) => f === filePath) : status.created;
for (const f of stagedFiles) { try { const { readFile } = await import('fs/promises'); const { join } = await import('path'); const content = await readFile(join(getRepoDir(), f), 'utf-8'); const lines = content.split('\n'); 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`; const body = lines.map((l) => '+' + l).join('\n'); result += '\n' + header + body + '\n'; } catch { // Skip files that can't be read } } }
return result;}
export async function getFileContent(filePath: string, ref: string = 'HEAD'): Promise<string> { const g = getGit(); try { return await g.show([`${ref}:${filePath}`]); } catch { return ''; }}
export async function stageFile(filePath: string): Promise<void> { const g = getGit(); await g.add(filePath);}
export async function unstageFile(filePath: string): Promise<void> { const g = getGit(); const hasCommits = await hasHead(); if (hasCommits) { await g.raw(['reset', 'HEAD', '--', filePath]); } else { await g.raw(['rm', '--cached', filePath]); }}
export async function stageAll(): Promise<void> { const g = getGit(); await g.add('-A');}
export async function resetFile(filePath: string): Promise<void> { const g = getGit(); const status = await g.status();
// Check if it's an untracked/new file if (status.not_added.includes(filePath) || status.created.includes(filePath)) { // Remove the file const { unlink } = await import('fs/promises'); const { join } = await import('path'); await unlink(join(getRepoDir(), filePath)); // If it was staged, also remove from index if (status.created.includes(filePath)) { await g.raw(['rm', '--cached', filePath]); } } else { // Modified or deleted file: restore to HEAD version await g.checkout(['HEAD', '--', filePath]); }}
export async function resetHunk(filePath: string, hunkHeader: string): Promise<void> { const g = getGit(); // Get the full diff for this file, then construct a reverse patch for just this hunk const diff = await g.diff(['HEAD', '--', filePath]); const hunks = diff.split(/(?=^@@)/m); const fileHeader = hunks[0] || '';
// Find the matching hunk const targetHunk = hunks.find((h) => h.startsWith(hunkHeader)); if (!targetHunk) return;
// Build a patch with just this hunk and apply in reverse const patch = fileHeader + targetHunk; const { writeFile, unlink } = await import('fs/promises'); const { join } = await import('path'); const { tmpdir } = await import('os'); const patchFile = join(tmpdir(), `lcr-patch-${Date.now()}.patch`); await writeFile(patchFile, patch); try { await g.raw(['apply', '--reverse', patchFile]); } finally { await unlink(patchFile).catch(() => {}); }}
export async function commitStaged(message: string): Promise<string> { const g = getGit(); const result = await g.commit(message); return result.commit;}
export async function commitAll(message: string): Promise<string> { const g = getGit(); await g.add('-A'); const result = await g.commit(message); return result.commit;}
export async function commit(message: string): Promise<string> { const g = getGit(); const result = await g.commit(message); return result.commit;}
export async function getDiffByMode(mode: DiffMode, filePath?: string, scope?: DiffScope): Promise<string> { if (mode === 'unstaged') { return getUnstagedDiff(filePath); } if (scope === 'worktree') { return getWorktreeDiff(filePath); } return getDiff(filePath);}
async function getUnstagedDiff(filePath?: string): Promise<string> { const g = getGit(); // git diff (no args) shows working tree vs index (unstaged changes only) const args: string[] = []; if (filePath) args.push('--', filePath); try { let result = await g.diff(args);
// Also include untracked files (they are always "unstaged") const status = await g.status(); const untrackedFiles = filePath ? status.not_added.filter((f) => f === filePath) : status.not_added;
for (const f of untrackedFiles) { try { const { readFile } = await import('fs/promises'); const { join } = await import('path'); const content = await readFile(join(getRepoDir(), f), 'utf-8'); const lines = content.split('\n'); 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`; const body = lines.map((l) => '+' + l).join('\n'); result += '\n' + header + body + '\n'; } catch { // Skip binary files } }
return result; } catch { return ''; }}
// --- Worktree (branch) diff support ---
let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> { if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
const g = getGit(); const currentBranch = (await g.revparse(['--abbrev-ref', 'HEAD'])).trim();
// Detached HEAD — can't determine base branch if (currentBranch === 'HEAD') { cachedBaseBranchInfo = { baseBranch: null, mergeBase: null }; return cachedBaseBranchInfo; }
const candidates = ['main', 'master', 'development', 'develop']; let bestBranch: string | null = null; let bestMergeBase: string | null = null; let bestTimestamp = 0;
for (const branch of candidates) { try { // Check if branch exists locally await g.raw(['rev-parse', '--verify', branch]);
// Skip if we're on this branch if (branch === currentBranch) continue;
const mergeBase = (await g.raw(['merge-base', 'HEAD', branch])).trim(); // Get the timestamp of the merge-base to pick the most recent one const timestamp = parseInt( (await g.raw(['log', '-1', '--format=%ct', mergeBase])).trim(), 10 );
if (timestamp > bestTimestamp) { bestTimestamp = timestamp; bestBranch = branch; bestMergeBase = mergeBase; } } catch { // Branch doesn't exist or merge-base failed — skip } }
// Fallback: try upstream tracking branch if (!bestBranch) { try { const upstream = (await g.raw(['rev-parse', '--abbrev-ref', '@{upstream}'])).trim(); // upstream is e.g. "origin/main" — extract the local branch name const localName = upstream.replace(/^[^/]+\//, ''); if (localName !== currentBranch) { const mergeBase = (await g.raw(['merge-base', 'HEAD', upstream])).trim(); bestBranch = localName; bestMergeBase = mergeBase; } } catch { // No upstream configured — skip } }
cachedBaseBranchInfo = { baseBranch: bestBranch, mergeBase: bestMergeBase }; return cachedBaseBranchInfo;}
async function getWorktreeDiff(filePath?: string): Promise<string> { const g = getGit(); const { mergeBase } = await getBaseBranchInfo(); if (!mergeBase) return getDiff(filePath); // Fallback to regular diff
const args = [mergeBase]; if (filePath) args.push('--', filePath); let result = ''; try { result = await g.diff(args); } catch { result = ''; }
// Also include untracked files (same as getDiff) const status = await g.status(); const untrackedFiles = filePath ? status.not_added.filter((f) => f === filePath) : status.not_added;
for (const f of untrackedFiles) { try { const { readFile } = await import('fs/promises'); const { join } = await import('path'); const content = await readFile(join(getRepoDir(), f), 'utf-8'); const lines = content.split('\n'); 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`; const body = lines.map((l) => '+' + l).join('\n'); result += '\n' + header + body + '\n'; } catch { // Skip files that can't be read } }
return result;}
export async function getWorktreeStatus(): Promise<FileChange[]> { const g = getGit(); const { mergeBase } = await getBaseBranchInfo(); if (!mergeBase) return getStatus(); // Fallback
// Run all independent git calls in parallel const [nameStatus, gitStatus, diffStat] = await Promise.all([ g.diff(['--name-status', mergeBase]), g.status(), g.diff([mergeBase, '--numstat']).catch(() => '') ]);
const files: FileChange[] = []; const seen = new Set<string>();
// Parse files changed between merge-base and working tree for (const line of nameStatus.split('\n')) { if (!line.trim()) continue; const match = line.match(/^([AMDRC])\d*\t(.+?)(?:\t(.+))?$/); if (!match) continue; const [, statusChar, filePath, newPath] = match; const path = newPath || filePath; if (seen.has(path)) continue; seen.add(path);
let status: FileChange['status'] = 'modified'; let oldPath: string | undefined; if (statusChar === 'A') status = 'added'; else if (statusChar === 'D') status = 'deleted'; else if (statusChar === 'R' || statusChar === 'C') { status = 'renamed'; oldPath = filePath; }
files.push({ path, oldPath, status, staged: false, approved: false, additions: 0, deletions: 0 }); }
// Include untracked files for (const f of gitStatus.not_added) { if (seen.has(f)) continue; seen.add(f); files.push({ path: f, status: 'added', staged: false, approved: false, additions: 0, deletions: 0 }); }
// Merge staging info from git status + worktree approvals const stagedSet = new Set([...gitStatus.created, ...gitStatus.staged]); const approvals = getWorktreeApprovals(); for (const file of files) { if (stagedSet.has(file.path) || approvals.has(file.path)) { file.staged = true; file.approved = true; } }
// Apply numstat additions/deletions for (const line of diffStat.split('\n')) { const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/); if (match) { const [, adds, dels, path] = match; const file = files.find((f) => f.path === path); if (file) { file.additions = adds === '-' ? 0 : parseInt(adds, 10); file.deletions = dels === '-' ? 0 : parseInt(dels, 10); } } }
return files;}
export async function getRepoInfo(): Promise<{ branch: string; repoName: string }> { const g = getGit(); const branch = await g.revparse(['--abbrev-ref', 'HEAD']); const repoDir = getRepoDir(); const repoName = repoDir.split('/').pop() || 'unknown'; return { branch: branch.trim(), repoName };}
// --- Unified VCS interface helpers (git implementation) ---
export async function approveFile(filePath: string, scope?: DiffScope): Promise<void> { if (scope === 'worktree') { approveWorktreeFile(filePath); } else { await stageFile(filePath); }}
export async function unapproveFile(filePath: string, scope?: DiffScope): Promise<void> { if (scope === 'worktree') { unapproveWorktreeFile(filePath); } else { await unstageFile(filePath); }}
export async function getOldRef(scope: DiffScope = 'uncommitted'): Promise<string> { if (scope === 'worktree') { const { mergeBase } = await getBaseBranchInfo(); if (mergeBase) return mergeBase; } return 'HEAD';}
// Stacked-change navigation is a jj-only feature.export async function getNewRef(): Promise<string | null> { return null;}
export async function getChangeStack(): Promise<ChangeStack> { return { main: null, current: null, selected: null, workingCopy: null, changes: [] };}
export async function selectChange(_changeId: string): Promise<ChangeStack> { throw new Error('Selecting changes is only supported in jj repositories.');}
export async function approveChange(_changeId?: string): Promise<ChangeStack> { throw new Error('Approving changes is only supported in jj repositories.');}