mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Git running inside a WSL distro writes `.git` gitdir pointers, and answers
`status --porcelain`, in the guest namespace. Node reads both back in the
Windows main process, where `/mnt/c/repo/.git` resolves to `C:\mnt\c\repo\.git`
and `/home/me/wt` names nothing at all. Four fs probes were built on those
fabricated paths and always came back "absent":
- `detectConflictOperation`'s four marker probes, so merge/rebase/cherry-pick
badges silently went missing.
- `parseUnmergedEntry`'s compat existence check, so every `deleted_by_us` /
`added_by_them` conflict rendered as 'deleted' regardless of the working tree.
- `findExistingWorktreeSymlinkPaths`' `lstat` from status, so Orca's own shared
symlinks (node_modules and friends) showed as user changes.
- the same `lstat` from the hosted-review dirty preflight, which fails closed:
an unreadable shared symlink read as uncommitted work and blocked PR/MR
creation outright.
`resolveGitDir` computes the host spelling of the worktree once and uses it for
both the gitfile read and the pointer resolve, so a guest-spelled worktree path
is reached at all, and a relative pointer (`worktree.useRelativePaths`, git
2.48+) resolves against a spelling Win32 understands. The pointer itself now
goes through the already-landed `resolveGitMetadataPath`, and the function gains
an optional `{ wslDistro }` for a caller whose base path does not encode a
distro. `detectConflictOperation` forwards it, and the three callers that reach
it -- status-read, the runtime RPC, the `git:conflictOperation` IPC -- pass the
git options they already hold. The return type stays `Promise<string>`.
`resolveWorktreeHostPath` is the same rule applied to a worktree path, used by
status-read for the two working-tree probes and by the review preflight. Both it
and `resolveGitMetadataPath` now treat only a single-leading-slash path as guest
namespace: `//wsl.localhost/...` is already a host UNC spelling, and translating
it prepended a second share prefix.
`readWorktreeDiffStamp` needed the same one-namespace guarantee, since moving
translation inside `resolveGitDir` would otherwise make its HEAD and index real
while the working-tree stat stayed fabricated, letting a settled diff survive
every edit. #17896 landed that change first, so it is no longer in this diff;
its version is a superset and all four components already resolve from one
`hostWorktreePath`. What remains here is the `resolveGitDir` gitfile-pointer
fix that #17896 explicitly deferred, which `worktree-diff-stamp-host-paths.test.ts`
pins.
`getConflictCompatibilityStatus` moves from `existsSync` to async `access`, for
the same reason `detectConflictOperation` did: once these paths are real they
are `\\wsl.localhost\...` shares, and a sync probe per asymmetric conflict
blocks the Electron main thread for a 9p round trip on every status poll.
Per-platform delta:
- native Windows, no WSL: no behavioral change. Nothing here starts with a
single `/`, so no path is translated. An absolute pointer is now returned
verbatim rather than separator-normalized; every consumer re-joins or
normalizes it before use.
- macOS/Linux: no change. Guest-pointer translation is gated to win32, and a
caller-named distro is ignored off Windows.
- Windows + WSL: drvfs pointers and drvfs-spelled worktrees now resolve to their
drive spelling instead of `C:\mnt\...`; a non-drvfs guest path resolves
through the named distro's UNC share, or stays verbatim (ENOENT -> existing
fail-safe) when none is named.
- SSH/relay: none. Those paths return before any of this via the provider
branch; `src/relay/git-handler-status-ops.ts` keeps its own resolveGitDir.
- folder workspaces, GitLab: none. Neither is on these code paths.
732 lines
26 KiB
TypeScript
732 lines
26 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,
|
|
accessMock
|
|
} = 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(),
|
|
accessMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./runner', () =>
|
|
createGitRunnerModuleMock({
|
|
gitExecFileAsyncMock,
|
|
gitExecFileAsyncBufferMock,
|
|
gitStreamOptionsMock
|
|
})
|
|
)
|
|
|
|
vi.mock('fs/promises', () =>
|
|
createFsPromisesModuleMock({
|
|
lstatMock,
|
|
realpathMock,
|
|
readFileMock,
|
|
statMock,
|
|
rmMock,
|
|
accessMock
|
|
})
|
|
)
|
|
|
|
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()
|
|
accessMock.mockReset()
|
|
accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
|
// 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')
|
|
accessMock.mockImplementation(async (target: string) => {
|
|
if (target.endsWith('MERGE_HEAD')) {
|
|
return undefined
|
|
}
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
|
})
|
|
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')
|
|
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 working-tree probe fails for a non-absence reason', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }))
|
|
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')
|
|
})
|
|
|
|
// Why both cases normalize separators: git reports the worktree in the WSL guest namespace, and
|
|
// the assertion is about which path is probed, not which separator this host's `path` emits.
|
|
it('probes the conflict working tree through the distro spelling on Windows', async () => {
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
|
readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n')
|
|
accessMock.mockImplementation(async (target: string) => {
|
|
if (String(target).endsWith('new.ts')) {
|
|
return undefined
|
|
}
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
|
})
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout:
|
|
'u DU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n'
|
|
})
|
|
|
|
try {
|
|
const result = await getStatus('/home/me/repo/feature', { wslDistro: 'Ubuntu' })
|
|
|
|
const probed = accessMock.mock.calls.map(([target]) => String(target).replaceAll('\\', '/'))
|
|
expect(probed).toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts')
|
|
expect(result.entries[0]?.status).toBe('modified')
|
|
expect(result.entries[0]?.conflictKind).toBe('deleted_by_us')
|
|
// The conflict-marker probes travel the same way.
|
|
expect(
|
|
probed.filter((target) =>
|
|
target.startsWith('//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/feature/')
|
|
)
|
|
).toHaveLength(4)
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('probes shared symlinks through the distro spelling on Windows', async () => {
|
|
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
|
readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n')
|
|
lstatMock.mockResolvedValue({ isSymbolicLink: () => true })
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? node_modules\n' })
|
|
|
|
try {
|
|
const result = await getStatus('/home/me/repo/feature', {
|
|
wslDistro: 'Ubuntu',
|
|
sharedLinkPaths: ['node_modules']
|
|
})
|
|
|
|
expect(String(lstatMock.mock.calls[0]?.[0]).replaceAll('\\', '/')).toContain(
|
|
'//wsl.localhost/Ubuntu/home/me/repo/feature/node_modules'
|
|
)
|
|
expect(result.entries).toEqual([])
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('passes core.quotePath=false and round-trips UTF-8 paths', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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('omits line stats without overwriting the reusable line-stats cache', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /stats-repo/.git/worktrees/feature\n')
|
|
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
|
if (args.includes('status')) {
|
|
return Promise.resolve({
|
|
stdout:
|
|
'# branch.oid head-stats\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: '' })
|
|
})
|
|
|
|
const withStats = await getStatus('/stats-repo')
|
|
const withoutStats = await getStatus('/stats-repo', { includeLineStats: false })
|
|
const reused = await getStatus('/stats-repo', { reuseLineStats: true })
|
|
|
|
expect(withoutStats.entries).toEqual(
|
|
withStats.entries.map(({ added: _added, removed: _removed, ...entry }) => entry)
|
|
)
|
|
expect(reused.entries).toEqual(withStats.entries)
|
|
expect(
|
|
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
|
|
).toHaveLength(1)
|
|
})
|
|
|
|
it('reuses unchanged line stats only when the safety hint is present', async () => {
|
|
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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 () => {
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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')
|
|
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)
|
|
})
|
|
})
|