Files
orca/src/main/git/status.test.ts
T

392 lines
12 KiB
TypeScript

/* 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 path from 'path'
const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, readFileMock, rmMock, existsSyncMock } =
vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileAsyncBufferMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileAsyncBuffer: gitExecFileAsyncBufferMock
}))
vi.mock('fs/promises', () => ({
readFile: readFileMock,
rm: rmMock
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock
}))
import {
bulkStageFiles,
bulkUnstageFiles,
detectConflictOperation,
discardChanges,
getBranchCompare,
getDiff,
getStatus,
isWithinWorktree
} from './status'
describe('discardChanges', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
readFileMock.mockReset()
rmMock.mockReset()
})
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', '--', 'src/file.ts'],
{
cwd: '/repo'
}
)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['restore', '--worktree', '--source=HEAD', '--', '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(1)
// Why: discardChanges uses path.resolve(worktreePath, filePath) to build
// the absolute rm target, which on Windows prepends a drive letter.
expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), {
force: true,
recursive: true
})
})
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()
})
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)],
{
cwd: '/repo'
}
)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['add', '--', ...filePaths.slice(200)],
{
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)],
{
cwd: '/repo'
}
)
})
})
describe('getDiff', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReset()
})
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('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', '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('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'
})
})
})
describe('getStatus', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReset()
})
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')
})
})
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: '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: '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' },
{ path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed' },
{ path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied' }
])
})
it('returns invalid-base when the compare ref does not resolve', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.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' })
.mockRejectedValueOnce(new Error('unborn'))
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('returns no-merge-base when histories do not intersect', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\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([])
})
})