695 lines
23 KiB
TypeScript
695 lines
23 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,
|
|
gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({
|
|
...env,
|
|
GIT_OPTIONAL_LOCKS: '0'
|
|
})
|
|
}))
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
readFile: readFileMock,
|
|
rm: rmMock
|
|
}))
|
|
|
|
vi.mock('fs', () => ({
|
|
existsSync: existsSyncMock
|
|
}))
|
|
|
|
import {
|
|
bulkStageFiles,
|
|
bulkDiscardChanges,
|
|
bulkUnstageFiles,
|
|
detectConflictOperation,
|
|
discardChanges,
|
|
getBranchCompare,
|
|
getDiff,
|
|
getStagedCommitContext,
|
|
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()
|
|
rmMock.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'
|
|
}
|
|
)
|
|
})
|
|
|
|
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', '--', 'src/file.ts', 'src/new-file.ts', 'docs', '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', '--', 'src/file.ts', 'docs'],
|
|
{
|
|
cwd: '/repo'
|
|
}
|
|
)
|
|
expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), {
|
|
force: true,
|
|
recursive: true
|
|
})
|
|
expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'scratch'), {
|
|
force: true,
|
|
recursive: true
|
|
})
|
|
})
|
|
|
|
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()
|
|
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('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('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')
|
|
})
|
|
|
|
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'
|
|
],
|
|
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
|
|
)
|
|
expect(result.entries).toEqual([
|
|
{ path: 'docs/日本語/sample.md', status: 'modified', area: 'unstaged' }
|
|
])
|
|
})
|
|
|
|
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'
|
|
],
|
|
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
|
|
)
|
|
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 without an extra git call', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
existsSyncMock.mockReturnValue(false)
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n'
|
|
})
|
|
|
|
const result = await getStatus('/repo')
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
|
})
|
|
|
|
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'
|
|
],
|
|
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
|
|
)
|
|
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'
|
|
],
|
|
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
|
|
)
|
|
expect(result.ignoredPaths).toEqual(['dist/', '.env', 'coverage/'])
|
|
expect(result.entries).toEqual([])
|
|
})
|
|
})
|
|
|
|
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
|
|
}
|
|
)
|
|
})
|
|
})
|
|
|
|
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:
|
|
'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' })
|
|
.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'))
|
|
.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' })
|
|
.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: '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: '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(
|
|
5,
|
|
[
|
|
'-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 }
|
|
])
|
|
})
|
|
})
|