Files
orca/src/main/git/status-diff.test.ts
T
OrcaWinandOrcaWin cb783ef09a fix(git): read diff blobs with cat-file so glob paths cannot fall back to HEAD commit (#34778)
git show :<path> and git show HEAD:<path> treat a missing blob spec as a
pathspec, exit 0, and print the HEAD commit when the path contains [, *
or ?. Orca then showed the commit header as the original side of new
files such as app/[id]/page.tsx. Read index and tree blobs with
git cat-file blob, and on older Git that only reports 'Not a valid
object name' prove absence with an empty literal ls-files/ls-tree probe.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-30 01:49:36 -07:00

610 lines
20 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as BoundedFileReader from '../../shared/node-bounded-file-reader'
import path from 'node:path'
import {
MAX_RENDERED_DIFF_COMBINED_CHARACTERS,
MAX_RENDERED_DIFF_LINES_PER_SIDE
} from '../../shared/large-diff-render-limit'
import {
createBoundedFileReaderModuleMock,
createFsPromisesModuleMock,
createGitRunnerModuleMock
} from './status-test-harness'
const {
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
gitStreamOptionsMock,
lstatMock,
realpathMock,
readFileMock,
statMock,
rmMock,
existsSyncMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileAsyncBufferMock: vi.fn(),
gitStreamOptionsMock: vi.fn(),
lstatMock: vi.fn(),
realpathMock: vi.fn(),
readFileMock: vi.fn(),
statMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn()
}))
vi.mock('./runner', () =>
createGitRunnerModuleMock({
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
gitStreamOptionsMock
})
)
vi.mock('fs/promises', () =>
createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock })
)
vi.mock('fs', () => ({
existsSync: existsSyncMock
}))
vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
createBoundedFileReaderModuleMock(await importOriginal<typeof BoundedFileReader>(), {
readFileMock,
statMock
})
)
import { getBranchDiff, getCommitDiff, getDiff, getStagedCommitContext, stageFile } from './status'
function deferredBuffer(content: string): {
promise: Promise<{ stdout: Buffer }>
resolve: () => void
} {
let resolve!: (value: { stdout: Buffer }) => void
const promise = new Promise<{ stdout: Buffer }>((innerResolve) => {
resolve = innerResolve
})
return {
promise,
resolve: () => resolve({ stdout: Buffer.from(content) })
}
}
async function waitForMockCalls(mock: ReturnType<typeof vi.fn>, calls: number): Promise<void> {
for (let i = 0; i < 20; i++) {
if (mock.mock.calls.length >= calls) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}
describe('getDiff', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
lstatMock.mockReset()
readFileMock.mockReset()
statMock.mockReset()
existsSyncMock.mockReset()
statMock.mockResolvedValue({
isFile: () => true,
size: 12
})
})
it('uses the index as the left side for unstaged diffs when present', async () => {
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.ts', false)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(
['cat-file', 'blob', '--', ':src/file.ts'],
{
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024,
preferWslDirectGit: true
}
)
expect(readFileMock).toHaveBeenCalledWith(path.join('/repo', 'src/file.ts'))
expect(result).toEqual({
kind: 'text',
originalContent: 'index-content\n',
modifiedContent: 'working-tree-content',
originalIsBinary: false,
modifiedIsBinary: false
})
})
it('normalizes Windows separators before reading git blobs', async () => {
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
await getDiff('/repo', 'src\\file.ts', false)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(
['cat-file', 'blob', '--', ':src/file.ts'],
{
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024,
preferWslDirectGit: true
}
)
})
it('falls back to HEAD for unstaged diffs when the file is not in the index', async () => {
gitExecFileAsyncBufferMock
.mockRejectedValueOnce(
Object.assign(new Error("fatal: path 'src/file.ts' exists on disk, but not in the index"), {
code: 128,
stderr: "fatal: path 'src/file.ts' exists on disk, but not in the index"
})
)
.mockResolvedValueOnce({ stdout: Buffer.from('head-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.ts', false)
expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(
2,
['cat-file', 'blob', '--', 'HEAD:src/file.ts'],
{
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024,
preferWslDirectGit: true
}
)
expect(result.originalContent).toBe('head-content\n')
expect(result.modifiedContent).toBe('working-tree-content')
})
it('marks binary content in the diff payload', async () => {
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from([0x00, 0x61, 0x62]) })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.bin', false)
expect(result.kind).toBe('binary')
expect(result.originalIsBinary).toBe(true)
expect(result.modifiedIsBinary).toBe(false)
})
it('does not read oversized working-tree files into memory', async () => {
const workingTreePath = path.join('/repo', 'dist/large.log')
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
// Route stats by path so stamp reads cannot consume the oversized-file response.
statMock.mockImplementation(async (target: string) =>
target === workingTreePath
? { isFile: () => true, size: 10 * 1024 * 1024 + 1 }
: { isFile: () => true, size: 12 }
)
const result = await getDiff('/repo', 'dist/large.log', false)
expect(readFileMock).not.toHaveBeenCalledWith(workingTreePath)
expect(result.kind).toBe('binary')
expect(result.modifiedIsBinary).toBe(true)
expect(result.modifiedContent).toBe('')
})
it('omits over-limit text bodies before returning the diff payload', async () => {
const oversizedText = 'a'.repeat(MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1)
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
statMock.mockResolvedValueOnce({
isFile: () => true,
size: oversizedText.length
})
readFileMock.mockResolvedValue(Buffer.from(oversizedText))
const result = await getDiff('/repo', 'dist/large.log', false)
expect(result.kind).toBe('text')
if (result.kind !== 'text') {
throw new Error('expected text diff result')
}
expect(result.originalContent).toBe('')
expect(result.modifiedContent).toBe('')
expect(result.largeDiffRenderLimit?.limited).toBe(true)
if (result.largeDiffRenderLimit?.limited !== true) {
throw new Error('expected large diff render limit')
}
expect(result.largeDiffRenderLimit.reason).toBe('character-count')
expect(result.largeDiffRenderLimit.characterCount).toBe(
oversizedText.length + 'index-content\n'.length
)
})
it('omits over-limit text bodies when line-count exceeds the cap', async () => {
const oversizedByLines = 'x\n'.repeat(MAX_RENDERED_DIFF_LINES_PER_SIDE)
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
statMock.mockResolvedValueOnce({
isFile: () => true,
size: oversizedByLines.length
})
readFileMock.mockResolvedValue(Buffer.from(oversizedByLines))
const result = await getDiff('/repo', 'dist/large-lines.log', false)
expect(result.kind).toBe('text')
if (result.kind !== 'text') {
throw new Error('expected text diff result')
}
expect(result.originalContent).toBe('')
expect(result.modifiedContent).toBe('')
expect(result.largeDiffRenderLimit?.limited).toBe(true)
if (result.largeDiffRenderLimit?.limited !== true) {
throw new Error('expected large diff render limit')
}
expect(result.largeDiffRenderLimit.reason).toBe('line-count')
expect(result.largeDiffRenderLimit.lineCounts?.modified).toBeGreaterThan(
MAX_RENDERED_DIFF_LINES_PER_SIDE
)
})
it('marks git blobs that overflow maxBuffer as binary instead of pretending they are missing', async () => {
gitExecFileAsyncBufferMock.mockRejectedValueOnce(
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })
)
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.txt', false)
expect(result.kind).toBe('binary')
expect(result.originalIsBinary).toBe(true)
expect(result.originalContent).toBe('')
})
it('includes preview metadata for pdf diffs', async () => {
const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00])
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pdfBuffer })
readFileMock.mockResolvedValue(pdfBuffer)
const result = await getDiff('/repo', 'docs/spec.pdf', false)
expect(result).toEqual({
kind: 'binary',
originalContent: pdfBuffer.toString('base64'),
modifiedContent: pdfBuffer.toString('base64'),
originalIsBinary: true,
modifiedIsBinary: true,
isImage: true,
mimeType: 'application/pdf'
})
})
it('flags a deleted image so previewers can fall back to the original bytes', async () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])
const workingTreePath = path.join('/repo', 'assets/deleted.png')
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer })
statMock.mockImplementation(async (target: string) => {
if (target === workingTreePath) {
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}
return { isFile: () => true, size: 12 }
})
const result = await getDiff('/repo', 'assets/deleted.png', false)
expect(result.kind).toBe('binary')
if (result.kind !== 'binary') {
throw new Error('expected binary diff result')
}
expect(result.modifiedDeleted).toBe(true)
expect(result.originalContent).toBe(pngBuffer.toString('base64'))
expect(result.modifiedContent).toBe('')
})
it('does not treat an unreadable working-tree image as a deletion', async () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])
const workingTreePath = path.join('/repo', 'assets/unreadable.png')
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer })
statMock.mockImplementation(async () => ({ isFile: () => true, size: 5 }))
readFileMock.mockImplementation(async (target: string) => {
if (target === workingTreePath) {
throw new Error('EIO')
}
return Buffer.from('')
})
const result = await getDiff('/repo', 'assets/unreadable.png', false)
expect(result.kind).toBe('binary')
if (result.kind !== 'binary') {
throw new Error('expected binary diff result')
}
expect(result.modifiedDeleted).toBeUndefined()
})
it('coalesces concurrent identical staged diff reads while in flight', async () => {
const leftBlob = deferredBuffer('head-content\n')
const rightBlob = deferredBuffer('index-content\n')
const pendingBuffers = [leftBlob, rightBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBuffers.shift()!.promise)
const reads = Array.from({ length: 8 }, () => getDiff('/repo', 'src/file.ts', true))
// Eight coalesced reads should spawn only once for each independent side.
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
leftBlob.resolve()
rightBlob.resolve()
const results = await Promise.all(reads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
expect(results.every((result) => result.kind === 'text')).toBe(true)
gitExecFileAsyncBufferMock
.mockResolvedValueOnce({ stdout: Buffer.from('fresh-head\n') })
.mockResolvedValueOnce({ stdout: Buffer.from('fresh-index\n') })
await getDiff('/repo', 'src/file.ts', true)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(4)
})
it('clears pending diff reads when a mutation runs', async () => {
const firstBlob = deferredBuffer('head-content\n')
const secondBlob = deferredBuffer('fresh-head-content\n')
const pendingBuffers = [firstBlob, secondBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBuffers.shift()!.promise)
readFileMock.mockResolvedValue(Buffer.from('working-tree\n'))
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
const first = getDiff('/repo', 'src/file.ts', false)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
await stageFile('/repo', 'src/file.ts')
const second = getDiff('/repo', 'src/file.ts', false)
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
firstBlob.resolve()
secondBlob.resolve()
await Promise.all([first, second])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['add', '--', ':(literal)src/file.ts'],
expect.objectContaining({ cwd: '/repo' })
)
})
it('coalesces concurrent identical branch and commit diff reads while in flight', async () => {
const branchLeftBlob = deferredBuffer('branch-left\n')
const branchRightBlob = deferredBuffer('branch-right\n')
const pendingBranchBuffers = [branchLeftBlob, branchRightBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBranchBuffers.shift()!.promise)
const branchReads = Array.from({ length: 8 }, () =>
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
branchLeftBlob.resolve()
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
branchRightBlob.resolve()
await Promise.all(branchReads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
gitExecFileAsyncBufferMock.mockReset()
const commitLeftBlob = deferredBuffer('commit-left\n')
const commitRightBlob = deferredBuffer('commit-right\n')
const pendingCommitBuffers = [commitLeftBlob, commitRightBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingCommitBuffers.shift()!.promise)
const commitReads = Array.from({ length: 8 }, () =>
getCommitDiff('/repo', {
parentOid: 'd'.repeat(40),
commitOid: 'e'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
commitLeftBlob.resolve()
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
commitRightBlob.resolve()
await Promise.all(commitReads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
})
it('coalesces logically identical branch and commit diff args regardless of property order', async () => {
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') })
await Promise.all([
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
}),
getBranchDiff('/repo', {
oldPath: 'src/old-file.ts',
filePath: 'src/file.ts',
headOid: 'c'.repeat(40),
mergeBase: 'b'.repeat(40)
})
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
gitExecFileAsyncBufferMock.mockClear()
await Promise.all([
getCommitDiff('/repo', {
parentOid: 'd'.repeat(40),
commitOid: 'e'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
}),
getCommitDiff('/repo', {
oldPath: 'src/old-file.ts',
filePath: 'src/file.ts',
commitOid: 'e'.repeat(40),
parentOid: 'd'.repeat(40)
})
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
})
it('keeps distinct diff inputs on separate in-flight reads', async () => {
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree\n'))
await Promise.all([
getDiff('/repo', 'src/file.ts', false, false),
getDiff('/repo', 'src/file.ts', false, true),
getDiff('/repo', 'src/file.ts', true, false),
getDiff('/repo', 'src/file.ts', true, false, { wslDistro: 'ubuntu' }),
getDiff('/repo', 'src/file.ts', true, false, { wslDistro: 'debian' })
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(8)
gitExecFileAsyncBufferMock.mockReset()
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') })
await Promise.all([
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts'
}),
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'd'.repeat(40),
filePath: 'src/file.ts'
}),
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-a.ts'
}),
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-b.ts'
}),
getCommitDiff('/repo', {
parentOid: 'e'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts'
}),
getCommitDiff('/repo', {
parentOid: 'a'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts'
}),
getCommitDiff('/repo', {
parentOid: 'e'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-a.ts'
}),
getCommitDiff('/repo', {
parentOid: 'e'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-b.ts'
})
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(16)
})
it('coalesces parentless root commit diff reads without reading a left-side blob', async () => {
const rightBlob = deferredBuffer('root-content\n')
gitExecFileAsyncBufferMock.mockImplementation(async () => rightBlob.promise)
const reads = Array.from({ length: 8 }, () =>
getCommitDiff('/repo', {
parentOid: null,
commitOid: 'e'.repeat(40),
filePath: 'src/file.ts'
})
)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
rightBlob.resolve()
await Promise.all(reads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(1)
})
})
describe('getStagedCommitContext', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('uses explicit large buffers before prompt truncation', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature/ai\n' })
.mockResolvedValueOnce({ stdout: 'M\tREADME.md\n' })
.mockResolvedValueOnce({ stdout: 'diff --git a/README.md b/README.md\n+hello\n' })
const result = await getStagedCommitContext('/repo')
expect(result).toEqual({
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: 'diff --git a/README.md b/README.md\n+hello\n'
})
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['diff', '--cached', '--name-status'], {
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024
})
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'],
{
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024
}
)
})
it('falls back to the file summary when the staged patch overflows the buffer', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature/ai\n' })
.mockResolvedValueOnce({ stdout: 'A\thuge.jsonl\n' })
.mockRejectedValueOnce(
Object.assign(new Error('stdout maxBuffer length exceeded'), {
code: 'ENOBUFS'
})
)
const result = await getStagedCommitContext('/repo')
expect(result).toEqual({
branch: 'feature/ai',
stagedSummary: 'A\thuge.jsonl',
stagedPatch: ''
})
})
it('rethrows staged patch failures that are not buffer overflows', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature/ai\n' })
.mockResolvedValueOnce({ stdout: 'M\tREADME.md\n' })
.mockRejectedValueOnce(new Error('fatal: bad revision'))
await expect(getStagedCommitContext('/repo')).rejects.toThrow('fatal: bad revision')
})
})