mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
* refactor(tests): split oversized test files off the max-lines suppression list Every `*.test.ts`/`*.spec.ts` that carried an `eslint/oxlint-disable max-lines` directive is now split into focused, behavior-scoped suites that fit the 800-line test budget, with shared setup extracted into co-located `*-test-harness.ts` / `*-test-fixtures.ts` modules (300-line budget). 83 files became ~930; the largest output is 797 effective lines. `orca-runtime.test.ts` is intentionally untouched. Test bodies were moved by scripted line-range slicing rather than retyped, so assertions are byte-identical. The only permitted body edits were mechanical rebinding where a shared value moved into a harness (e.g. `tmpHome` -> `homes.tmpHome`). Registries that enumerate test files were updated in lockstep: - config/max-lines-baseline.txt: pruned 341 -> 258 entries (all 83 removed). - config/reliability-gates.jsonc: 33 gates repointed at the split files, with assertionRefs split per file where a gate's coverage now spans several. - .github/workflows/pr.yml: the real-zsh lane now lists the 4 split files that actually exercise zsh, so they keep running in the dedicated shell lane. Also renamed agent-hooks `server-test-fixtures.ts` to `server.test-fixtures.ts` so the global-fetch call-site audit keeps skipping it, and added `.js` extensions to the CLI suites' dynamic harness imports (node16 resolution) to unbreak `build:cli`. Verification: full suite 52,449 passing vs 52,448 at baseline with zero assertions lost; `pnpm lint`, `pnpm typecheck`, and `pnpm build:cli` all exit 0; the terminal-pane e2e spec runs 31/31 headless. * refactor(tests): split hook-idle arbitration suite that oxfmt pushed over budget The pre-commit oxfmt pass reflowed pty-connection-hook-idle-arbitration.test.ts to 811 effective lines, 11 over the test budget. Split the hook-completion side effect and replacement-agent veto cases into their own suite; both files now sit well under the cap and the 15 tests are unchanged. * test: port upstream test changes into the split files after rebase Rebasing onto main surfaced 27 tests that main had added to files this branch deleted, plus edits to tests that had already moved. Taking the deletion side of those modify/delete conflicts would have dropped that coverage silently, so each upstream change is ported into the split file that now owns the behavior — for example main's six orchestration mailbox tests land across orchestration-runs, -send, and -check. Also repoints `orchestration.notification-mailbox-consistency`, a gate main added after this branch's gate remap, at those same three split files, and re-prunes the max-lines baseline against main's (257 entries). Verified: all 27 upstream test titles present; full suite 52,761 passing with the only diff vs baseline being 12 tests main itself removed and 3 that moved from skipped to passing; lint and typecheck exit 0. * fix(test): flush pending continuations before tearing down terminal test globals CI shard 5/16 failed on both Node 24 and 26 with `ReferenceError: window is not defined` from pty-connection.ts, surfacing through pty-connection-daemon-snapshot-replay.test.ts. The reattach/settle chains `await` a real promise and then touch `window.api`. Under fake timers those continuations cannot run, so they only become schedulable once restoreTerminalTestGlobals() switches back to real timers — which previously happened immediately before `delete globalThis.window`, so a late continuation threw and failed the whole file. Flush async ticks in that window instead. This is latent in the source rather than new: the pre-split 25k-line file kept running other tests after these, which gave the chains time to settle before teardown. Splitting the file moved teardown directly behind them. * fix(test): keep an inert window after terminal test teardown instead of deleting it The async-tick flush was not enough: the reattach/settle chain can resolve after teardown regardless of how long we drain, so CI shard 5/16 still failed with `ReferenceError: window is not defined` from pty-connection.ts. A real renderer never loses `window`, so deleting it was the artificial part. Swap in an inert proxy whose properties resolve to callables and whose calls resolve to undefined, making a late `window.api.pty.*` call a harmless no-op. The next test replaces it wholesale via installTerminalTestGlobals(), and no test asserts that `window` is absent.
665 lines
24 KiB
TypeScript
665 lines
24 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type * as BoundedFileReader from '../../shared/node-bounded-file-reader'
|
|
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 { clearEffectiveUpstreamStatusCacheForTests, getStatus, stageFile } from './status'
|
|
|
|
describe('getStatus', () => {
|
|
beforeEach(() => {
|
|
clearEffectiveUpstreamStatusCacheForTests()
|
|
gitExecFileAsyncMock.mockReset()
|
|
gitExecFileAsyncBufferMock.mockReset()
|
|
gitStreamOptionsMock.mockReset()
|
|
lstatMock.mockReset()
|
|
readFileMock.mockReset()
|
|
existsSyncMock.mockReset()
|
|
// Why: untracked line counting stats a file before reading it; any
|
|
// under-limit size routes the read to readFileMock.
|
|
statMock.mockReset()
|
|
statMock.mockResolvedValue({ isFile: () => true, size: 12 })
|
|
// 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('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', preferWslDirectGit: true }
|
|
)
|
|
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('reuses unchanged line stats only when the safety hint is present', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
existsSyncMock.mockReturnValue(false)
|
|
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
|
if (args.includes('status')) {
|
|
return Promise.resolve({
|
|
stdout:
|
|
'# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
|
|
})
|
|
}
|
|
if (args.includes('--numstat')) {
|
|
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
|
|
}
|
|
return Promise.resolve({ stdout: '' })
|
|
})
|
|
|
|
await getStatus('/repo')
|
|
const reused = await getStatus('/repo', { reuseLineStats: true })
|
|
await getStatus('/repo')
|
|
|
|
expect(reused.entries).toEqual([
|
|
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 }
|
|
])
|
|
expect(
|
|
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
it('recomputes after a scan whose numstat failed instead of pinning missing counts', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
existsSyncMock.mockReturnValue(false)
|
|
let failNumstat = true
|
|
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
|
if (args.includes('status')) {
|
|
return Promise.resolve({
|
|
stdout:
|
|
'# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
|
|
})
|
|
}
|
|
if (args.includes('--numstat')) {
|
|
if (failNumstat) {
|
|
failNumstat = false
|
|
return Promise.reject(new Error('transient index.lock'))
|
|
}
|
|
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
|
|
}
|
|
return Promise.resolve({ stdout: '' })
|
|
})
|
|
|
|
const failed = await getStatus('/repo')
|
|
const reused = await getStatus('/repo', { reuseLineStats: true })
|
|
|
|
expect(failed.entries[0]?.added).toBeUndefined()
|
|
expect(reused.entries).toEqual([
|
|
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 }
|
|
])
|
|
})
|
|
|
|
it('invalidates safety reuse for a new head and for known mutations', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
existsSyncMock.mockReturnValue(false)
|
|
let head = 'head-1'
|
|
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
|
if (args.includes('status')) {
|
|
return Promise.resolve({
|
|
stdout:
|
|
`# branch.oid ${head}\n` + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
|
|
})
|
|
}
|
|
if (args.includes('--numstat')) {
|
|
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
|
|
}
|
|
return Promise.resolve({ stdout: '' })
|
|
})
|
|
|
|
await getStatus('/repo')
|
|
head = 'head-2'
|
|
await getStatus('/repo', { reuseLineStats: true })
|
|
await stageFile('/repo', 'src/unstaged.ts')
|
|
await getStatus('/repo', { reuseLineStats: true })
|
|
|
|
expect(
|
|
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
|
|
).toHaveLength(3)
|
|
})
|
|
|
|
it('isolates line-stat reuse between WSL distributions', 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/unstaged.ts\n'
|
|
})
|
|
}
|
|
if (args.includes('--numstat')) {
|
|
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
|
|
}
|
|
return Promise.resolve({ stdout: '' })
|
|
})
|
|
|
|
await getStatus('/repo', { wslDistro: 'ubuntu' })
|
|
await getStatus('/repo', { wslDistro: 'debian', reuseLineStats: true })
|
|
await getStatus('/repo', { wslDistro: 'ubuntu', reuseLineStats: true })
|
|
|
|
expect(
|
|
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
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'],
|
|
expect.objectContaining({
|
|
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('caps unmerged conflicts and keeps the visible conflict rows', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
existsSyncMock.mockReturnValue(true)
|
|
const lines = [
|
|
'u UU S... 160000 160000 160000 160000 aa bb cc vendor/submodule',
|
|
...Array.from(
|
|
{ length: 3 },
|
|
(_, i) => `u UU N... 100644 100644 100644 100644 aa bb cc conflict-${i}.ts`
|
|
)
|
|
].join('\n')
|
|
gitExecFileAsyncMock.mockReset()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: `${lines}\n` })
|
|
|
|
const result = await getStatus('/repo', { limit: 2 })
|
|
|
|
expect(result.didHitLimit).toBe(true)
|
|
expect(result.statusLength).toBe(3)
|
|
expect(result.entries).toHaveLength(2)
|
|
expect(result.entries.map((entry) => entry.path)).toEqual(['conflict-0.ts', 'conflict-1.ts'])
|
|
expect(result.entries.every((entry) => entry.conflictStatus === 'unresolved')).toBe(true)
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps an early conflict ahead of later ordinary rows at the cap', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
existsSyncMock.mockReturnValue(true)
|
|
const lines = [
|
|
'? before.ts',
|
|
'u UU N... 100644 100644 100644 100644 aa bb cc conflict.ts',
|
|
'? after.ts'
|
|
].join('\n')
|
|
gitExecFileAsyncMock.mockReset()
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: `${lines}\n` })
|
|
|
|
const result = await getStatus('/repo', { limit: 2 })
|
|
|
|
expect(result.didHitLimit).toBe(true)
|
|
expect(result.entries.map((entry) => entry.path)).toEqual(['before.ts', 'conflict.ts'])
|
|
expect(result.entries[1]).toMatchObject({
|
|
conflictKind: 'both_modified',
|
|
conflictStatus: 'unresolved'
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|