mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* perf(source-control): stop blocking main on four sync git-dir probes per status poll detectConflictOperation ran four existsSync calls against the git dir on every status poll. On a `\\wsl.localhost\...` worktree each one is a 9p round trip, and being synchronous they landed on the Electron main thread back to back. Replace them with concurrent fs/promises access probes: same "any failure reads as absent" semantics existsSync had, one wave instead of four serialized blocking calls. The outer try/catch went with them -- neither resolveGitDir nor the probes can throw now, so it was unreachable. Part of #15036 (source-control latency). * perf(wsl): let git reads take the shell-free route from a cwd-derived distro shouldAttemptWslDirectGit required options.wslDistro, so a `\\wsl.localhost\...` worktree without a resolved WSL project runtime never qualified -- even though the distro is right there in the cwd and wslDistroForCommand already knew how to read it. Every `git show` behind a diff therefore ran through the user's login shell, executing their rc once per blob read. Three changes: - Derive the distro from the cwd when no override was supplied. This is the fix; the routing decision now depends on where the repo actually lives. - Wait, bounded, for a cold read-environment probe instead of resolving without it. The probe is one wsl.exe call shared per distro, so the wait is paid at most once, and past WSL_GIT_READ_ENVIRONMENT_WAIT_MS the shell route runs exactly as before. It returns null rather than a settled promise when there is nothing to wait for, so a non-WSL git call is not pushed into a later microtask. - Opt the blob reads into preferWslDirectGit via gitReadOptionsForWorktree (renamed from gitStatusReadOptionsForWorktree; it was never status-specific). Belt-and- braces only: `show`, `config --get-regexp`, `ls-files` and `rev-parse` were all already matched by isWslDirectGitReadCommand, so this changes no routing today -- it just stops the diff path depending on a heuristic it knows the answer to. git-blob-read also gains a `failed` flag distinguishing "git ran and reported the path absent" (exit 128) from "the read never got an answer"; nothing consumes it yet, the settled diff cache does. Part of #15036 (source-control latency). * perf(source-control): give diff reads a settled cache keyed on stamped git state gitDiffReadDedupe coalesces only while a read is in flight, so every file selection re-ran the whole read: a `git config --file .gitmodules` spawn, one or two `git show` spawns, and a working-tree stat+read. On a WSL/UNC worktree each git spawn is a wsl.exe invocation, which is the ">3s Loading diff..." in #15036. Correctness first -- a stale diff is worse than a slow one. The cache never expires on a clock and there is no TTL to tune. Instead: - worktree-diff-stamp.ts takes a subprocess-free stamp of exactly the inputs a file diff is built from: HEAD (by resolved tip *content*, so a commit is visible even though HEAD's own bytes never move), `.git/index` (mtime+size), `.gitmodules` (submodule routing), and the working-tree file. A linked worktree's commondir and the packed-refs/reftable fallback are handled; an unborn branch is caught by recording "no loose ref" rather than only the packed stamps. - The stamp is captured BEFORE the read and stored with the result. Anything that moves during or after the read leaves the stored stamp behind, so the next lookup misses. That, not a freshness window, is why a stale diff cannot be served. - A store is refused unless the stamp was taken a full mtime bucket (2s, FAT's granularity) after its newest component. Below that, a second write inside the same bucket would be invisible -- git's own racy-index rule. - `null` stamp means "cannot prove" and never caches: a folder workspace, a repo whose layout cannot be read, or a filesystem reporting no usable mtime. - Submodule routes and reads that failed rather than proved absence are not reusable. A wsl.exe hiccup produces the same empty left side a new file does, and pinning that would persist a wrong diff. - invalidateGitReadCaches clears it and bumps a generation, so a read that started pre-mutation cannot store its result post-mutation. `ino` is deliberately optional in the working-tree component: Windows reports 0 for it on the redirector behind `\\wsl.localhost`, and requiring an unstable 0 to match would make the cache silently never hit on the exact host it exists for. Cache counters are exposed for the same reason -- a miss storm and a cold start otherwise look identical. Also drops gitDiffReadDedupe.clear() from getStatus. A status poll is a read; all it did was destroy a live coalescing entry so a concurrent identical request started duplicate git work. Mutations still invalidate through the shared point. Memory is bounded by retained characters, not entry count -- one diff result can legitimately hold megabytes. Fixes the source-control half of #15036. * perf(source-control): reuse BoundedMap and stop the WSL probe wait from outliving its answer Review follow-ups on the settled-diff-cache work: - SettledDiffCache now sits on the shared BoundedMap instead of hand-rolling the same Map + character ledger + evict-oldest loop. - pendingWslDirectGitReadEnvironment returns null once the probe has settled either way, so a distro whose direct route was disabled no longer pays for a 1.5s timer and two microtask hops on every git read. - That wait now honours the read's abort signal and goes through withTimeout, so an aborted read is not held for the full bound and a probe rejection can never surface as a read failure. - The settled-cache generation fence is taken before the stamp read, so a mutation that lands entirely inside the stamp's stats can no longer store an entry whose stamp is torn across it. - The cache counters are folded into the main-thread churn probe report, which is what tells a permanently-cold cache apart from a cold start in the field. * fix(source-control): tell WSL clock skew apart from a genuinely fresh write The racy-write margin compares two clocks: capturedAtMs is this host's, while the component mtimes come from whatever wrote the files. On a \\wsl.localhost worktree the guest sets them, so a guest running ahead pushes every recently-touched file past the margin and the cache refuses to store — for as long as the skew lasts, on exactly the platform this cache exists for. Nothing was wrong with the refusal; it was invisible. racyWrites alone cannot distinguish "the repo was just edited" from "the clocks disagree and this will never resolve on its own", so a permanently cold cache looked like a cold start. isDiffStampClockSkewed flags the one thing no local write can produce — an mtime in this host's future — and the cache counts those separately as clockSkewedWrites. A nonzero count is the signal that the cache is off for a reason idling will not fix. Found by review of #16600; behavior is unchanged, only observability.
601 lines
20 KiB
TypeScript
601 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(['show', ':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(['show', ':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(new Error('missing 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,
|
|
['show', '--end-of-options', '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') })
|
|
// Why by path: the diff also stats git-dir entries to stamp its inputs, so a
|
|
// one-shot queue would hand the oversized size to whichever stat ran first.
|
|
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))
|
|
|
|
// Why both up front: the two sides are independent spawns issued concurrently,
|
|
// so 8 identical reads still collapse to exactly 2 — one per side, not per read.
|
|
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')
|
|
})
|
|
})
|