/** * Higher-level git operations extracted from git-handler.ts. * * Why: oxlint max-lines requires files to stay under 300 lines. * These async operations accept a git executor callback so they * remain decoupled from the GitHandler class. */ import * as path from 'node:path' import { bufferToBlob, parseBranchDiff } from './git-handler-utils' import { buildDiffResult } from './git-diff-result' import { isGitBufferOverflowError } from './git-buffer-overflow' import { readWorkingDiffFile } from './git-working-file-read' // ─── Executor types ────────────────────────────────────────────────── export type GitExec = ( args: string[], cwd: string, opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string; timeout?: number } ) => Promise<{ stdout: string; stderr: string }> export type GitBufferExec = (args: string[], cwd: string) => Promise // ─── Blob reading ──────────────────────────────────────────────────── export async function readBlobAtOid( gitBuffer: GitBufferExec, cwd: string, oid: string, filePath: string ): Promise<{ content: string; isBinary: boolean }> { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const buf = await gitBuffer(['show', '--end-of-options', `${oid}:${gitPath}`], cwd) return bufferToBlob(buf, filePath) } catch (error) { if (isGitBufferOverflowError(error)) { return { content: '', isBinary: true } } return { content: '', isBinary: false } } } export async function readBlobAtIndex( gitBuffer: GitBufferExec, cwd: string, filePath: string ): Promise<{ content: string; isBinary: boolean }> { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd) return bufferToBlob(buf, filePath) } catch (error) { if (isGitBufferOverflowError(error)) { return { content: '', isBinary: true } } return { content: '', isBinary: false } } } export async function readUnstagedLeft( gitBuffer: GitBufferExec, cwd: string, filePath: string ): Promise<{ content: string; isBinary: boolean }> { const index = await readBlobAtIndex(gitBuffer, cwd, filePath) if (index.content || index.isBinary) { return index } return readBlobAtOid(gitBuffer, cwd, 'HEAD', filePath) } // ─── Diff ──────────────────────────────────────────────────────────── export async function computeDiff( git: GitBufferExec, worktreePath: string, filePath: string, staged: boolean, compareAgainstHead = false ) { let originalContent = '' let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false try { if (staged) { const left = await readBlobAtOid(git, worktreePath, 'HEAD', filePath) originalContent = left.content originalIsBinary = left.isBinary const right = await readBlobAtIndex(git, worktreePath, filePath) modifiedContent = right.content modifiedIsBinary = right.isBinary } else { const left = compareAgainstHead ? await readBlobAtOid(git, worktreePath, 'HEAD', filePath) : await readUnstagedLeft(git, worktreePath, filePath) originalContent = left.content originalIsBinary = left.isBinary const right = await readWorkingDiffFile(path.join(worktreePath, filePath)) modifiedContent = right.content modifiedIsBinary = right.isBinary } } catch { // Fallback to empty } return buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) } // ─── Branch compare ────────────────────────────────────────────────── export async function branchCompare( git: GitExec, worktreePath: string, baseRef: string, loadBranchChanges: (mergeBase: string, headOid: string) => Promise[]> ) { const summary: Record = { baseRef, baseOid: null, compareRef: 'HEAD', headOid: null, mergeBase: null, changedFiles: 0, status: 'loading' } try { const { stdout: branchOut } = await git(['branch', '--show-current'], worktreePath) const branch = branchOut.trim() if (branch) { summary.compareRef = branch } } catch { /* keep HEAD */ } let headOid: string let baseOid = '' try { const { stdout } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath) headOid = stdout.trim() summary.headOid = headOid } catch { try { const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath) baseOid = stdout.trim() summary.baseOid = baseOid // Why: new remote worktrees can be on an unborn branch until the first // commit. There are no committed branch changes yet; surfacing this as a // compare error makes the source-control panel look broken. summary.changedFiles = 0 summary.commitsAhead = 0 summary.status = 'ready' return { summary, entries: [] } } catch { // Preserve the existing unborn-head message when even the base is not // resolvable; callers cannot compare or present a useful empty state. } summary.status = 'unborn-head' summary.errorMessage = 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' return { summary, entries: [] } } try { const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath) baseOid = stdout.trim() summary.baseOid = baseOid } catch { summary.status = 'invalid-base' summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` return { summary, entries: [] } } let mergeBase: string try { const { stdout } = await git(['merge-base', baseOid, headOid], worktreePath) mergeBase = stdout.trim() summary.mergeBase = mergeBase } catch { summary.status = 'no-merge-base' summary.errorMessage = `This branch and ${baseRef} do not share a merge base, so compare-to-base is unavailable.` return { summary, entries: [] } } try { const entries = await loadBranchChanges(mergeBase, headOid) const { stdout: countOut } = await git( ['rev-list', '--count', `${baseOid}..${headOid}`], worktreePath ) summary.changedFiles = entries.length summary.commitsAhead = Number.parseInt(countOut.trim(), 10) || 0 summary.status = 'ready' return { summary, entries } } catch (error) { summary.status = 'error' summary.errorMessage = error instanceof Error ? error.message : 'Failed to load branch compare' return { summary, entries: [] } } } // ─── Branch diff ───────────────────────────────────────────────────── export async function branchDiffEntries( git: GitExec, gitBuffer: GitBufferExec, worktreePath: string, baseRef: string, opts: { includePatch?: boolean; filePath?: string; oldPath?: string } ) { let headOid: string let mergeBase: string try { const { stdout: headOut } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath) headOid = headOut.trim() const { stdout: baseOut } = await git(['rev-parse', '--verify', baseRef], worktreePath) const baseOid = baseOut.trim() const { stdout: mbOut } = await git(['merge-base', baseOid, headOid], worktreePath) mergeBase = mbOut.trim() } catch { return [] } // Why: see core.quotePath rationale in getStatusOp — keep UTF-8 paths intact. const { stdout } = await git( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], worktreePath ) const allChanges = parseBranchDiff(stdout) // Why: the IPC handler for single-file branch diff sends filePath/oldPath // to avoid reading blobs for every changed file — only the matched file. let changes = allChanges if (opts.filePath) { changes = allChanges.filter( (c) => c.path === opts.filePath || c.oldPath === opts.filePath || (opts.oldPath && (c.path === opts.oldPath || c.oldPath === opts.oldPath)) ) } if (!opts.includePatch) { return changes.map(() => ({ kind: 'text', originalContent: '', modifiedContent: '', originalIsBinary: false, modifiedIsBinary: false })) } const results: Record[] = [] for (const change of changes) { const fp = change.path as string const oldP = (change.oldPath as string) ?? fp try { const left = await readBlobAtOid(gitBuffer, worktreePath, mergeBase, oldP) const right = await readBlobAtOid(gitBuffer, worktreePath, headOid, fp) results.push(buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, fp)) } catch { results.push({ kind: 'text', originalContent: '', modifiedContent: '', originalIsBinary: false, modifiedIsBinary: false }) } } return results } export { validateGitExecArgs } from './git-exec-validator'