orca/src/relay/git-handler-ops.ts

291 lines
9.4 KiB
TypeScript

/**
* 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 '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 }
) => Promise<{ stdout: string; stderr: string }>
export type GitBufferExec = (args: string[], cwd: string) => Promise<Buffer>
// ─── Blob reading ────────────────────────────────────────────────────
export async function readBlobAtOid(
gitBuffer: GitBufferExec,
cwd: string,
oid: string,
filePath: string
): Promise<{ content: string; isBinary: boolean }> {
// Why: Git's `<oid>:<path>` 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 `:<path>` 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<Record<string, unknown>[]>
) {
const summary: Record<string, unknown> = {
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 = 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<string, unknown>[] = []
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'