/* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'fs' import path from 'path' import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS, MAX_RENDERED_DIFF_LINES_PER_SIDE } from '../../shared/large-diff-render-limit' const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, lstatMock, realpathMock, readFileMock, statMock, rmMock, existsSyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileAsyncBufferMock: vi.fn(), lstatMock: vi.fn(), realpathMock: vi.fn(), readFileMock: vi.fn(), statMock: vi.fn(), rmMock: vi.fn(), existsSyncMock: vi.fn() })) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileAsyncBuffer: gitExecFileAsyncBufferMock, // Why: getStatus now streams status output. The mock pulls the next queued // stdout from gitExecFileAsyncMock and feeds it to onStdout, so existing tests // that seed the status call via `gitExecFileAsyncMock.mockResolvedValueOnce` // keep working unchanged and call ordering (status, then numstat) is preserved. gitStreamStdout: async ( args: string[], options: { onStdout: (chunk: string) => boolean | void } ) => { // Forward args so arg-routing mock implementations (e.g. `args.includes`) // still match the status read. const { stdout } = await gitExecFileAsyncMock(args) const stoppedEarly = options.onStdout(stdout ?? '') === true return { stoppedEarly } }, gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ ...env, GIT_OPTIONAL_LOCKS: '0' }) })) vi.mock('fs/promises', () => ({ lstat: lstatMock, realpath: realpathMock, readFile: readFileMock, stat: statMock, rm: rmMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock })) import { abortMerge, abortRebase, bulkStageFiles, bulkDiscardChanges, bulkUnstageFiles, clearEffectiveUpstreamStatusCacheForTests, detectConflictOperation, getBranchDiff, discardChanges, getCommitDiff, getBranchCompare, getCommitCompare, getDiff, getStagedCommitContext, getStatus, isWithinWorktree, 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, calls: number): Promise { for (let i = 0; i < 20; i++) { if (mock.mock.calls.length >= calls) { return } await new Promise((resolve) => setTimeout(resolve, 0)) } } describe('discardChanges', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() lstatMock.mockReset() realpathMock.mockReset() readFileMock.mockReset() rmMock.mockReset() lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) it('restores tracked files from HEAD', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'src/file.ts\n' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await discardChanges('/repo', 'src/file.ts') expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['ls-files', '--error-unmatch', '--', ':(literal)src/file.ts'], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts'], { cwd: '/repo' } ) expect(rmMock).not.toHaveBeenCalled() }) it('removes untracked files from disk', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not tracked')) await discardChanges('/repo', 'src/new-file.ts') expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['clean', '-ffdx', '--', ':(literal)src/new-file.ts'], { cwd: '/repo' } ) expect(rmMock).not.toHaveBeenCalled() }) it('rejects paths that traverse outside the worktree', async () => { await expect(discardChanges('/repo', '../../etc/passwd')).rejects.toThrow( 'resolves outside the worktree' ) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(rmMock).not.toHaveBeenCalled() }) it('accepts in-tree Windows paths when resolving containment', async () => { expect(isWithinWorktree(path.win32, 'C:\\repo', 'C:\\repo\\src\\file.ts')).toBe(true) }) }) describe('bulk git helpers', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() lstatMock.mockReset() realpathMock.mockReset() rmMock.mockReset() lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) it('chunks bulk stage requests to avoid oversized argv payloads', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) const filePaths = Array.from({ length: 201 }, (_, i) => `src/file-${i}.ts`) await bulkStageFiles('/repo', filePaths) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['add', '--', ...filePaths.slice(0, 100).map((filePath) => `:(literal)${filePath}`)], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['add', '--', ...filePaths.slice(200).map((filePath) => `:(literal)${filePath}`)], { cwd: '/repo' } ) }) it('chunks bulk unstage requests to avoid oversized argv payloads', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) const filePaths = Array.from({ length: 101 }, (_, i) => `src/file-${i}.ts`) await bulkUnstageFiles('/repo', filePaths) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, [ 'restore', '--staged', '--', ...filePaths.slice(100).map((filePath) => `:(literal)${filePath}`) ], { cwd: '/repo' } ) }) it('discards tracked and untracked paths in bulk', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'src/file.ts\0docs/readme.md\0' }) .mockResolvedValueOnce({ stdout: '' }) await bulkDiscardChanges('/repo', ['src/file.ts', 'src/new-file.ts', 'docs', 'scratch']) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ 'ls-files', '-z', '--', ':(literal)src/file.ts', ':(literal)src/new-file.ts', ':(literal)docs', ':(literal)scratch' ], { cwd: '/repo' } ) // Why: a pathspec is tracked if git reports either the exact path or a // tracked descendant, which keeps directory pathspecs on the restore path. expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts', ':(literal)docs'], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['clean', '-ffdx', '--', ':(literal)src/new-file.ts', ':(literal)scratch'], { cwd: '/repo' } ) expect(rmMock).not.toHaveBeenCalled() }) it('handles large tracked path lists during bulk discard classification', async () => { const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`) .join('\0') .concat('\0') gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: trackedStdout }).mockResolvedValueOnce({ stdout: '' }) await bulkDiscardChanges('/repo', ['docs']) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['restore', '--worktree', '--source=HEAD', '--', ':(literal)docs'], { cwd: '/repo' } ) expect(rmMock).not.toHaveBeenCalled() }) it('rejects bulk discard paths that traverse outside the worktree', async () => { await expect(bulkDiscardChanges('/repo', ['src/file.ts', '../outside.txt'])).rejects.toThrow( 'resolves outside the worktree' ) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(rmMock).not.toHaveBeenCalled() }) }) 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 }) 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 }) }) 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 } ) 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 () => { gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) statMock.mockResolvedValueOnce({ isFile: () => true, size: 10 * 1024 * 1024 + 1 }) const result = await getDiff('/repo', 'dist/large.log', false) expect(readFileMock).not.toHaveBeenCalled() 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('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)) await waitForMockCalls(gitExecFileAsyncBufferMock, 1) expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(1) leftBlob.resolve() await waitForMockCalls(gitExecFileAsyncBufferMock, 2) 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('getStatus', () => { beforeEach(() => { clearEffectiveUpstreamStatusCacheForTests() gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() // Why: after the status call, getStatus may issue `git diff --numstat` // calls to attach per-entry line counts. Tests that don't care about counts // set only a `mockResolvedValueOnce` for the status output; this default // keeps those follow-up numstat calls from returning undefined. gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) it('benchmarks concurrent status burst subprocess pressure', async () => { const benchPath = process.env.ORCA_GIT_STATUS_COALESCING_BENCH_JSON if (!benchPath) { return } readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ stdout: '' }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: '' }) } return Promise.resolve({ stdout: '' }) }) const startedAt = performance.now() await Promise.all(Array.from({ length: 10 }, () => getStatus('/repo'))) const durationMs = performance.now() - startedAt const statusCommandCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('status') ).length const { mkdirSync, writeFileSync } = await vi.importActual('fs') mkdirSync(path.dirname(benchPath), { recursive: true }) writeFileSync( benchPath, JSON.stringify({ scenario: 'git-status-concurrent-burst', concurrentCalls: 10, statusCommandCalls, durationMs }) ) }) it('coalesces identical in-flight status reads without caching after settle', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) let statusCommandCalls = 0 const releaseStatusReads: (() => void)[] = [] gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { statusCommandCalls += 1 return new Promise<{ stdout: string }>((resolve) => { releaseStatusReads.push(() => resolve({ stdout: '' })) }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: '' }) } return Promise.resolve({ stdout: '' }) }) const sharedRead = Promise.all([getStatus('/repo'), getStatus('/repo'), getStatus('/repo')]) await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) releaseStatusReads.splice(0).forEach((release) => release()) await sharedRead expect(statusCommandCalls).toBe(1) const settledRead = getStatus('/repo') await vi.waitFor(() => expect(statusCommandCalls).toBe(2)) releaseStatusReads.splice(0).forEach((release) => release()) await settledRead expect(statusCommandCalls).toBe(2) }) it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockImplementation((target: string) => target.endsWith('MERGE_HEAD')) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'u UU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/app.ts\n' }) const result = await getStatus('/repo') expect(result.conflictOperation).toBe('merge') expect(result.entries).toEqual([ { path: 'src/app.ts', area: 'unstaged', status: 'modified', conflictKind: 'both_modified', conflictStatus: 'unresolved' } ]) }) it('maps deleted conflicts to deleted when the working tree file is absent', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n' }) const result = await getStatus('/repo') expect(result.entries[0]).toEqual({ path: 'src/deleted.ts', area: 'unstaged', status: 'deleted', conflictKind: 'deleted_by_them', conflictStatus: 'unresolved' }) }) it('falls back to modified when the filesystem existence check throws', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockImplementation(() => { throw new Error('stat failed') }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'u AU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n' }) const result = await getStatus('/repo') expect(result.entries[0]?.status).toBe('modified') expect(result.entries[0]?.conflictKind).toBe('added_by_us') }) it('passes core.quotePath=false and round-trips UTF-8 paths', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a docs/日本語/sample.md\n' }) const result = await getStatus('/repo') // Why: without -c core.quotePath=false git would emit // "docs/\346\227\245\346\234\254\350\252\236/sample.md" (octal-escaped, // wrapped in double quotes) and the parser would store that literal // string as entry.path, breaking sidebar display + downstream blob reads. expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ '-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all' ]) expect(result.entries).toEqual([ { path: 'docs/日本語/sample.md', status: 'modified', area: 'unstaged' } ]) }) it('preserves porcelain v2 submodule dirtiness flags on status rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n' }) const result = await getStatus('/repo') expect(result.entries).toEqual([ { path: 'nested-repo', status: 'added', area: 'staged', submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } }, { path: 'nested-repo', status: 'modified', area: 'unstaged', submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } } ]) }) it('omits ignored files by default and parses them when requested', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '! dist/\n! generated/file.js\n' }) const result = await getStatus('/repo', { includeIgnored: true }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ '-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all', '--ignored=matching' ]) expect(result.ignoredPaths).toEqual(['dist/', 'generated/file.js']) }) it('parses branch identity from porcelain v2 branch headers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a src/app.ts\n' }) const result = await getStatus('/repo') expect(result).toMatchObject({ head: 'abcdef1234567890', branch: 'refs/heads/feature/prompts' }) }) it('folds upstream ahead/behind from porcelain v2 into the status result', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n# branch.upstream origin/feature/prompts\n# branch.ab +2 -3\n' }) const result = await getStatus('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(result.upstreamStatus).toEqual({ hasUpstream: true, upstreamName: 'origin/feature/prompts', ahead: 2, behind: 3 }) }) it('reports no upstream from porcelain v2 status when no same-name origin branch exists', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === '-c' && args.includes('status')) { return Promise.resolve({ stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n' }) } if (args[0] === 'symbolic-ref') { return Promise.resolve({ stdout: 'feature/prompts\n' }) } if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { return Promise.reject(new Error('fatal: no upstream configured')) } if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature/prompts')) { return Promise.reject(new Error('missing remote branch')) } if (args[0] === 'config') { return Promise.reject(new Error(`missing ${args[2] ?? 'config'}`)) } throw new Error(`unexpected git args: ${args.join(' ')}`) }) const result = await getStatus('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/feature/prompts'], { cwd: '/repo' } ) expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) }) it('uses same-name origin branch status for legacy base-tracking worktrees', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n# branch.upstream origin/main\n# branch.ab +1 -0\n' }) .mockResolvedValueOnce({ stdout: 'feature/prompts\n' }) .mockResolvedValueOnce({ stdout: 'origin/main\n' }) .mockResolvedValueOnce({ stdout: 'abc123\n' }) .mockResolvedValueOnce({ stdout: '3\t1\n' }) const result = await getStatus('/repo') expect(result.upstreamStatus).toEqual({ hasUpstream: true, upstreamName: 'origin/feature/prompts', ahead: 3, behind: 1 }) }) it('omits --ignored and ignoredPaths when includeIgnored is not requested', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) const result = await getStatus('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ '-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all' ]) expect('ignoredPaths' in result).toBe(false) }) it('parses ! porcelain v2 records into ignoredPaths when includeIgnored is true', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '! dist/\n! .env\n! coverage/\n' }) const result = await getStatus('/repo', { includeIgnored: true }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ '-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all', '--ignored=matching' ]) expect(result.ignoredPaths).toEqual(['dist/', '.env', 'coverage/']) expect(result.entries).toEqual([]) }) it('attaches per-area line counts from staged and unstaged numstat', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ stdout: '1 M. N... 100644 100644 100644 aaaa aaaa src/staged.ts\n' + '1 .M N... 100644 100644 100644 bbbb bbbb src/unstaged.ts\n' }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: args.includes('--cached') ? '10\t0\tsrc/staged.ts\n' : '3\t4\tsrc/unstaged.ts\n' }) } return Promise.resolve({ stdout: '' }) }) const result = await getStatus('/repo') expect(result.entries).toEqual([ { path: 'src/staged.ts', status: 'modified', area: 'staged', added: 10, removed: 0 }, { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } ]) }) it('attaches numstat counts for literal paths containing rename markers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ stdout: '1 .M N... 100644 100644 100644 aaaa aaaa docs/a => b.txt\n' }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: '1\t0\tdocs/a => b.txt\0' }) } return Promise.resolve({ stdout: '' }) }) const result = await getStatus('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'], { cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) } ) expect(result.entries).toEqual([ { path: 'docs/a => b.txt', status: 'modified', area: 'unstaged', added: 1, removed: 0 } ]) }) it('attaches staged rename counts to the new path', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ stdout: '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: '2\t1\tsrc/old name.ts => src/new name.ts\n' }) } return Promise.resolve({ stdout: '' }) }) const result = await getStatus('/repo') expect(result.entries).toEqual([ { path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged', added: 2, removed: 1 } ]) }) it('counts untracked file contents as additions', async () => { existsSyncMock.mockReturnValue(false) lstatMock.mockResolvedValue({ size: 14, mtimeMs: 1, ctimeMs: 1, isFile: () => true, isSymbolicLink: () => false }) readFileMock.mockImplementation((target: string) => String(target).endsWith('.git') ? Promise.resolve('gitdir: /repo/.git/worktrees/feature\n') : Promise.resolve(Buffer.from('one\ntwo\nthree\n')) ) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? src/brand-new.ts\n' }) const result = await getStatus('/repo') expect(result.entries).toEqual([ { path: 'src/brand-new.ts', status: 'untracked', area: 'untracked', added: 3 } ]) }) it('leaves binary working-tree changes without counts', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ stdout: '1 .M N... 100644 100644 100644 cccc cccc assets/logo.png\n' }) } // git reports binary files as '-' in both numstat columns. if (args.includes('--numstat')) { return Promise.resolve({ stdout: '-\t-\tassets/logo.png\n' }) } return Promise.resolve({ stdout: '' }) }) const result = await getStatus('/repo') expect(result.entries).toEqual([ { path: 'assets/logo.png', status: 'modified', area: 'unstaged' } ]) }) it('skips numstat entirely for a clean working tree', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await getStatus('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('truncates and flags didHitLimit when entries exceed the limit', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) const stdout = `${Array.from({ length: 25 }, (_, i) => `? file${i}.txt`).join('\n')}\n` gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout }) const result = await getStatus('/repo', { limit: 10 }) expect(result.didHitLimit).toBe(true) expect(result.statusLength).toBeGreaterThan(10) // First `limit` entries are kept; the rest are dropped. expect(result.entries.length).toBe(10) // attachLineStats (numstat) must be skipped when the limit was hit — only // the single streamed status read should have happened. expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('does not flag didHitLimit for a normal repo under the limit', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? a.txt\n? b.txt\n' }) const result = await getStatus('/repo', { limit: 10 }) expect(result.didHitLimit).toBeUndefined() expect(result.entries.length).toBe(2) }) }) describe('abortMerge', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() }) it('runs git merge --abort in the worktree', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await abortMerge('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['merge', '--abort'], { cwd: '/repo' }) }) }) describe('abortRebase', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() }) it('runs git rebase --abort in the worktree', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await abortRebase('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rebase', '--abort'], { cwd: '/repo' }) }) }) 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') }) }) describe('detectConflictOperation', () => { beforeEach(() => { readFileMock.mockReset() existsSyncMock.mockReset() }) it('ignores a stale REBASE_HEAD when no rebase directory exists', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockImplementation((target: string) => { if (target.endsWith('MERGE_HEAD')) { return false } if (target.endsWith('CHERRY_PICK_HEAD')) { return false } if (target.endsWith('rebase-merge')) { return false } if (target.endsWith('rebase-apply')) { return false } if (target.endsWith('REBASE_HEAD')) { return true } return false }) const result = await detectConflictOperation('/repo') expect(result).toBe('unknown') }) }) describe('getBranchCompare', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() readFileMock.mockReset() }) it('returns a pinned branch compare snapshot and parsed branch entries', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'main\n' }) .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'head-oid\n' }) .mockResolvedValueOnce({ stdout: 'base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n' }) .mockResolvedValueOnce({ stdout: '10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n' }) .mockResolvedValueOnce({ stdout: '7\n' }) const result = await getBranchCompare('/repo', 'origin/main') expect(result.summary).toEqual({ baseRef: 'origin/main', baseOid: 'base-oid', compareRef: 'main', headOid: 'head-oid', mergeBase: 'merge-base-oid', changedFiles: 3, commitsAhead: 7, status: 'ready' }) expect(result.entries).toEqual([ { path: 'file-a.ts', status: 'modified', added: 10, removed: 2 }, { path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed', added: 1, removed: 1 }, { path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied', added: 3, removed: 0 } ]) }) it('returns invalid-base when the compare ref does not resolve', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'main\n' }) .mockRejectedValueOnce(new Error('missing remote base')) .mockRejectedValueOnce(new Error('missing local base')) .mockResolvedValueOnce({ stdout: 'head-oid\n' }) .mockRejectedValueOnce(new Error('missing base')) const result = await getBranchCompare('/repo', 'origin/missing') expect(result.summary.status).toBe('invalid-base') expect(result.summary.errorMessage).toContain('origin/missing') expect(result.entries).toEqual([]) }) it('returns unborn-head when HEAD cannot be resolved', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'main\n' }) .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) .mockRejectedValueOnce(new Error('unborn')) .mockRejectedValueOnce(new Error('missing base')) const result = await getBranchCompare('/repo', 'origin/main') expect(result.summary.status).toBe('unborn-head') expect(result.summary.errorMessage).toContain('committed HEAD') expect(result.entries).toEqual([]) }) it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\n' }) .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) .mockRejectedValueOnce(new Error('unborn')) .mockResolvedValueOnce({ stdout: 'base-oid\n' }) const result = await getBranchCompare('/repo', 'origin/main') expect(result.summary).toEqual({ baseRef: 'origin/main', baseOid: 'base-oid', compareRef: 'feature', headOid: null, mergeBase: null, changedFiles: 0, commitsAhead: 0, status: 'ready' }) expect(result.entries).toEqual([]) }) it('returns no-merge-base when histories do not intersect', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'main\n' }) .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'head-oid\n' }) .mockResolvedValueOnce({ stdout: 'base-oid\n' }) .mockRejectedValueOnce(new Error('no merge base')) const result = await getBranchCompare('/repo', 'origin/main') expect(result.summary.status).toBe('no-merge-base') expect(result.summary.errorMessage).toContain('merge base') expect(result.entries).toEqual([]) }) it('passes core.quotePath=false to diff --name-status and parses UTF-8 paths', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'main\n' }) .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'head-oid\n' }) .mockResolvedValueOnce({ stdout: 'base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' }) .mockResolvedValueOnce({ stdout: '2\t1\tdocs/日本語/sample.md\n' }) .mockResolvedValueOnce({ stdout: '1\n' }) const result = await getBranchCompare('/repo', 'origin/main') expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 6, [ '-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', 'merge-base-oid', 'head-oid' ], expect.objectContaining({ cwd: '/repo' }) ) expect(result.entries).toEqual([ { path: 'docs/日本語/sample.md', status: 'modified', added: 2, removed: 1 } ]) }) it('compares short remote labels through fully qualified remote-tracking refs', async () => { gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === 'branch') { return Promise.resolve({ stdout: 'feature\n' }) } if ( args[0] === 'rev-parse' && args.includes('--quiet') && args.includes('refs/remotes/origin/main^{commit}') ) { return Promise.resolve({ stdout: 'remote-base-oid\n' }) } if (args[0] === 'rev-parse' && args.includes('HEAD')) { return Promise.resolve({ stdout: 'head-oid\n' }) } if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { return Promise.resolve({ stdout: 'base-oid\n' }) } if (args[0] === 'merge-base') { return Promise.resolve({ stdout: 'merge-base-oid\n' }) } if (args.includes('--name-status')) { return Promise.resolve({ stdout: '' }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: '' }) } if (args[0] === 'rev-list') { return Promise.resolve({ stdout: '0\n' }) } throw new Error(`unexpected git args: ${args.join(' ')}`) }) const result = await getBranchCompare('/repo', 'origin/main') expect(result.summary).toMatchObject({ baseRef: 'origin/main', baseOid: 'base-oid', status: 'ready' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['rev-parse', '--verify', '--end-of-options', 'refs/remotes/origin/main'], { cwd: '/repo' } ) }) it('attaches counts for branch compare paths containing rename markers', async () => { gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === 'branch') { return Promise.resolve({ stdout: 'main\n' }) } if (args[0] === 'rev-parse' && args.includes('HEAD')) { return Promise.resolve({ stdout: 'head-oid\n' }) } if (args[0] === 'rev-parse') { return Promise.resolve({ stdout: 'base-oid\n' }) } if (args[0] === 'merge-base') { return Promise.resolve({ stdout: 'merge-base-oid\n' }) } if (args.includes('--name-status')) { return Promise.resolve({ stdout: 'M\tdocs/a => b.txt\n' }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: args.includes('-z') ? '1\t0\tdocs/a => b.txt\0' : '1\t0\tdocs/a => b.txt\n' }) } if (args[0] === 'rev-list') { return Promise.resolve({ stdout: '1\n' }) } throw new Error(`unexpected git args: ${args.join(' ')}`) }) const result = await getBranchCompare('/repo', 'origin/main') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ '-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', 'merge-base-oid', 'head-oid' ], expect.objectContaining({ cwd: '/repo' }) ) expect(result.entries).toEqual([ { path: 'docs/a => b.txt', status: 'modified', added: 1, removed: 0 } ]) }) }) describe('getCommitCompare', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() }) it('attaches counts for commit compare paths containing rename markers', async () => { gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === 'rev-parse') { return Promise.resolve({ stdout: 'commit-oid\n' }) } if (args[0] === 'rev-list') { return Promise.resolve({ stdout: 'commit-oid parent-oid\n' }) } if (args.includes('--name-status')) { return Promise.resolve({ stdout: 'M\tdocs/a => b.txt\n' }) } if (args.includes('--numstat')) { return Promise.resolve({ stdout: args.includes('-z') ? '1\t0\tdocs/a => b.txt\0' : '1\t0\tdocs/a => b.txt\n' }) } throw new Error(`unexpected git args: ${args.join(' ')}`) }) const result = await getCommitCompare('/repo', 'commit-oid') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ '-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', 'parent-oid', 'commit-oid' ], expect.objectContaining({ cwd: '/repo' }) ) expect(result.entries).toEqual([ { path: 'docs/a => b.txt', status: 'modified', added: 1, removed: 0 } ]) }) })