Files
orca/src/relay/git-handler-ops.ts
T
JinjingandOrca a03a3dd51b Render png on mobile (#9087)
* Add mobile image-diff previews via shared data-URI builder

- Extracts a `buildImageDataUri` helper (src/shared/image-data-uri.ts) shared by
  the desktop ImageViewer and mobile, so both trim whitespace-wrapped base64 and
  skip non-previewable mimes (e.g. application/pdf) the same way.
- Adds mobile-diff-image-preview.ts to render binary git.diff results (add/modify/
  delete) as images instead of falling back to "Binary preview unavailable".
- Extracts resolveMobileFileTabDoc to consolidate the session file-tab loading
  logic (diff/image/html/text) out of the route file for testability.

* Fix stale binary image fallback for empty modified diffs and relay reads

- mobileDiffImageDataUri now distinguishes a true deletion (modified
  side absent) from a modify whose binary bytes arrived empty
  (relay/size-cap cases), returning null instead of the stale
  pre-change image
- readWorkingDiffFile passes the file path to bufferToBlob so relay
  working-tree reads can detect previewable image extensions instead
  of always reporting empty binary content
- add mobile-file-tab-doc.test.ts covering diff/image/binary/text
  resolution paths

* Regenerate skill bundle manifest for 1.4.144-rc.2

Co-authored-by: Orca <help@stably.ai>

* fix(review): trim comments to AGENTS.md's one/two-line why-only rule

Comments in mobile-diff-image-preview.ts and mobile-file-tab-doc.ts ran
3-6 lines and narrated mechanism instead of stating only the non-obvious
reason, per AGENTS.md's "Code Comments: Document the Why, Briefly" rule.

Co-authored-by: Orca <help@stably.ai>

* Distinguish read failures from true deletions in binary diff results

- Working-tree stat/readFile errors and relay reads previously collapsed
  onto the same empty-content signal as a genuine deletion, letting
  previewers fall back to stale original bytes on a failed read.
- Add modifiedDeleted/missing flags through status.ts, git-handler-ops,
  and git-working-file-read so only proven deletions trigger the
  original-bytes fallback; failed reads now return null.
- Tighten buildImageDataUri to accept only image/* mimes instead of
  special-casing application/pdf.

* fix(relay): expect missing:false on index blob maxBuffer overflow

readBlobAtIndex now returns a missing flag so staged deletions are
distinct from size-capped binary reads; update the overflow test.

* Allow opening deleted files to show pre-delete text or image diffs

Deleted files can now be opened to view their pre-delete content via
git.diff (including images via modifiedDeleted). Only unresolved conflicts
remain unopenable. Centralizes the canOpen rule in canOpenMobileGitStatusEntry()
to keep opener guards consistent across the mobile source control UI.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-16 18:21:08 -07:00

308 lines
10 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 '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
signal?: AbortSignal
stdin?: string
timeout?: number
}
) => 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; missing: 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), missing: false }
} catch (error) {
if (isGitBufferOverflowError(error)) {
return { content: '', isBinary: true, missing: false }
}
// Why: a non-overflow failure means the path is absent from the index (a
// staged deletion), distinct from the size-capped case handled above.
return { content: '', isBinary: false, missing: true }
}
}
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
let modifiedDeleted = 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
modifiedDeleted = right.missing
} 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
modifiedDeleted = right.missing
}
} catch {
// Fallback to empty
}
const result = buildDiffResult(
originalContent,
modifiedContent,
originalIsBinary,
modifiedIsBinary,
filePath
)
// Why: mark a proven deletion so previewers can fall back to the original bytes
// without mistaking a read failure's empty modified side for a deletion.
if (result.kind === 'binary' && modifiedDeleted) {
return { ...result, modifiedDeleted: true }
}
return result
}
// ─── 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 = 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<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'