mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
Git can execute inside a WSL distro against a raw Linux worktree path while Node, on the Windows side, reads the same files back through Win32. `path.join( '/home/me/repo/feature', 'src/file.ts')` on win32 produces the drive-relative `\home\me\repo\feature\src\file.ts`, which resolves against whatever the current drive happens to be and almost always ENOENTs. The same mis-spelling hits the drvfs form, where `/mnt/c/repo` should read as `C:\repo`. Two consequences, both on the Node side only (git already works, because it gets the Linux path as its cwd and resolves it inside the distro): - getDiff's unstaged working-tree read missed, `readWorkingTreeFile` mapped ENOENT to `exists: false`, and an existing file rendered as DELETED in the diff view. - `readWorktreeDiffStamp` could not find `.git`, so the stamp was null, the settled diff cache neither hit nor stored, and every diff respawned `git show` - two `wsl.exe` spawns the cache exists specifically to avoid. Both now spell the worktree directory for the reading host first, via a new `resolveWorktreeHostPath` wrapper around the resolver that landed in #17804. The wrapper exists because `resolveGitMetadataPath` trims: a gitfile payload carries a trailing newline, but a directory name may legally begin or end with whitespace on POSIX, so the wrapper keeps the caller's spelling whenever the resolver only trimmed it. The stamp's opaque `value` still embeds the caller's original `worktreePath`, so settled-cache identity is byte-identical and no cache key moves. `readWorktreeDiffStamp` was already `Promise<WorktreeDiffStamp | null>` with one caller that treats null as a cache miss, so no new nullability enters the type system and the resolver's never-null-for-a-non-empty-pointer contract is untouched. The only unspellable input is an empty worktree path, handled locally as "not provably unchanged" in the stamp and as a read *failure* (not a proven deletion) in file-diff. What changes for users | Platform | Delta | |---|---| | macOS | No change. An absolute POSIX path is returned verbatim, including one whose directory name carries leading or trailing whitespace. | | Linux | No change. Same reason. | | Native Windows (no WSL) | No change. A `C:\...` or `\\server\share\...` path is already absolute for win32 and passes through verbatim. | | Windows + WSL, UNC worktree path (`\\wsl.localhost\Ubuntu\...`) | No change. Already absolute for win32; passes through verbatim. This is today's common case. | | Windows + WSL, drvfs worktree path (`/mnt/c/repo`) | Fixed. Reads as `C:\repo` instead of the drive-relative `\mnt\c\repo`. Needs no distro name. | | Windows + WSL, Linux worktree path with a named distro (`/home/me/repo`) | Fixed. Reads as `\\wsl.localhost\Ubuntu\home\me\repo`. The deleted-file misrender goes away and the diff cache starts hitting. | | Windows, POSIX path, no distro and not a drvfs mount | No change. Passes through verbatim, same ENOENT, same existing fallback. | | SSH | No change. `runtime-git-diff-commands.ts` and the `git:diff` IPC both route to `provider.getDiff` for a connection, so this local code is never reached. | | Relay / remote | No change. No RPC param, wire field, stream opcode, or published content is touched; the relay host runs the same local code and gets the same fix. | | Folder workspace (non-git) | No change. `.git` is absent either way, `resolveGitDir` returns the same fallback, and the stamp stays null exactly as today. | | GitLab / other providers | Not applicable. No provider-specific or review code is touched. | What this does NOT do - It does not fix `resolveGitDir` itself. For a drvfs repo whose worktree Orca already spells `C:\repo\feature`, the gitfile payload `gitdir: /mnt/c/repo/.git/ worktrees/feature` is still mis-resolved by `path.resolve` to `C:\mnt\c\repo\.git\...`, so the stamp still returns null in that shape. Separate change, separate PR; this one neither fixes nor regresses it. - It does not touch submodule path resolution. `resolveSubmoduleWorktreePath` is the path-escape guard and has a near-identical twin in the relay; changing it without escape tests on both is out of scope. - It does not change `readHeadComponent`'s `commondir` resolution. The relative `../..` git actually writes takes the identical `path.resolve` branch, and an absolute POSIX `commondir` under a WSL UNC `gitDir` already resolves correctly because the UNC root is `\\wsl.localhost\<distro>\`. - It does not reorder drvfs-before-UNC inside the shared resolver. That changes the identity of returned strings and needs a real Windows+WSL box. - It does not add any Git command, option, or version dependency. Costs and residual risk - One extra pure function call per diff read. No I/O added or removed on the unaffected paths. - Translation still trims. `resolveWorktreeHostPath` preserves whitespace only when no translation happened; a guest directory named `/home/me/repo ` loses its trailing space on a Windows reader. Reachable only on win32, where such a name is not addressable anyway, and the previous behavior for that shape was a drive-relative miss. - A relative worktree path (no caller passes one) is now resolved against the process cwd instead of joined relative to it. Same file in every case except a relative name that itself ends in whitespace. - `UNSPELLABLE_WORKING_TREE_READ`'s `exists`/`failed` fields are correct but not observable today: the stamp is null for the same input, so nothing can be cached and `reusable` cannot be read back. They are there so the branch stays right if `loadDiff` ever gains a second caller. The test pins the observable part - that no read lands on a cwd-relative path. - Every test here mocks `node:fs/promises` and spoofs `process.platform`. They prove which path string reaches `stat`/`readFile`, which is the right assertion, but none of this has executed against a real 9p mount on a Windows+WSL box and this repo's CI has no such runner. - Honest framing of the trigger: I could not demonstrate a mainline path that hands `getDiff` an untranslated POSIX worktree path on Windows today - `translateWslOutputPaths` UNC-translates worktree paths whenever a distro is known, `getWslHome` returns the UNC spelling, and `resolveWslRepoWorktreeBasePath` normalizes a configured Linux base. The drvfs case is the most plausible live one. Treat this as defense-in-depth that is a strict no-op on every configuration above except the two marked Fixed. Verification - `npx vitest run src/main/git src/shared/git-metadata-path.test.ts` -> 196 files / 2241 tests passed, 2 files and 5 tests skipped. One failure, `git-admission-storm-measurement.test.ts > reports bounded-concurrency before and after measurements` (ENOENT scandir on its own temp state dir), is pre-existing and environmental: it fails identically in isolation and spawns real git children without touching any changed module. - `npx vitest run src/main/git/status-diff-settled-cache.test.ts` -> 21/21 (16 pre-existing, 5 new). `npx vitest run src/shared/git-metadata-path.test.ts` -> 25/25 (19 pre-existing, 6 new cases across 3 new tests). - `npx oxfmt --write` then `npx oxlint` on all five changed files -> clean. Mutation checks - all eight production substitutions were reverted one at a time and the suite re-run. Each fails at least one test, and no new test survives its own mutation: | Reverted | Failing test | |---|---| | file-diff working-tree read -> `worktreePath` | reads the working tree through the host spelling instead of reporting a deletion; invalidates when the working tree file is edited under the host spelling | | stamp working-tree component -> `worktreePath` | invalidates when the working tree file is edited under the host spelling | | stamp `.gitmodules` stat -> `worktreePath` | invalidates when .gitmodules appears under the host spelling | | stamp `resolveGitDir` -> `worktreePath` | stamps through the host spelling so the second read does not respawn git | | `options` threading at the `readWorktreeDiffStamp` call | stamps through the host spelling...; invalidates when .gitmodules appears... | | wrapper's untrimmed preservation -> return the resolver's value | keeps whitespace that belongs to the directory name (both cases) | | `UNSPELLABLE_WORKING_TREE_READ` -> a cwd-relative `readWorkingTreeFile` | reads nothing relative to the cwd when the worktree path has no host spelling | | stamp's null early return -> `hostWorktreePath ?? worktreePath` | reads nothing relative to the cwd when the worktree path has no host spelling | The settled-cache tests seed the fake filesystem through the platform-bound `path` module rather than `path.win32`, so they assert real behavior on a POSIX CI host as well as on Windows and are not gated on the host platform. Co-authored-by: Neil <79079362+brennanb2025@users.noreply.github.com>
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import * as path from 'node:path'
|
|
import type * as BoundedFileReader from '../../shared/node-bounded-file-reader'
|
|
import { createBoundedFileReaderModuleMock, createGitRunnerModuleMock } from './status-test-harness'
|
|
|
|
const {
|
|
gitExecFileAsyncMock,
|
|
gitExecFileAsyncBufferMock,
|
|
gitStreamOptionsMock,
|
|
lstatMock,
|
|
realpathMock,
|
|
rmMock,
|
|
existsSyncMock
|
|
} = vi.hoisted(() => ({
|
|
gitExecFileAsyncMock: vi.fn(),
|
|
gitExecFileAsyncBufferMock: vi.fn(),
|
|
gitStreamOptionsMock: vi.fn(),
|
|
lstatMock: vi.fn(),
|
|
realpathMock: vi.fn(),
|
|
rmMock: vi.fn(),
|
|
existsSyncMock: vi.fn()
|
|
}))
|
|
|
|
/**
|
|
* A tiny in-memory filesystem, because every assertion here is about what the
|
|
* cache does when one specific path's mtime or bytes move. Sequenced
|
|
* `mockResolvedValueOnce` stacks cannot express that: the stamp and the diff
|
|
* read touch overlapping paths in an order the test should not have to know.
|
|
*/
|
|
type FakeFile = { content: Buffer; mtimeMs: number; ino: number }
|
|
|
|
const { files } = vi.hoisted(() => ({ files: new Map<string, FakeFile>() }))
|
|
|
|
const { readFileMock, statMock, accessMock } = vi.hoisted(() => {
|
|
const missing = (target: string): NodeJS.ErrnoException =>
|
|
Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' })
|
|
return {
|
|
readFileMock: vi.fn(async (target: string, encoding?: BufferEncoding) => {
|
|
const file = files.get(target)
|
|
if (!file) {
|
|
throw missing(target)
|
|
}
|
|
return encoding ? file.content.toString(encoding) : file.content
|
|
}),
|
|
statMock: vi.fn(async (target: string) => {
|
|
const file = files.get(target)
|
|
if (!file) {
|
|
throw missing(target)
|
|
}
|
|
return {
|
|
isFile: () => true,
|
|
size: file.content.byteLength,
|
|
mtimeMs: file.mtimeMs,
|
|
ino: file.ino
|
|
}
|
|
}),
|
|
accessMock: vi.fn(async (target: string) => {
|
|
if (!files.has(target)) {
|
|
throw missing(target)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
vi.mock('./runner', () =>
|
|
createGitRunnerModuleMock({
|
|
gitExecFileAsyncMock,
|
|
gitExecFileAsyncBufferMock,
|
|
gitStreamOptionsMock
|
|
})
|
|
)
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
lstat: lstatMock,
|
|
realpath: realpathMock,
|
|
readFile: readFileMock,
|
|
stat: statMock,
|
|
rm: rmMock,
|
|
access: accessMock
|
|
}))
|
|
|
|
vi.mock('fs', () => ({ existsSync: existsSyncMock }))
|
|
|
|
vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
|
|
createBoundedFileReaderModuleMock(await importOriginal<typeof BoundedFileReader>(), {
|
|
readFileMock,
|
|
statMock
|
|
})
|
|
)
|
|
|
|
import { getDiff, getStatus, invalidateGitReadCaches, stageFile } from './status'
|
|
import { settledDiffCache } from './source-control/git-read-cache-invalidation'
|
|
|
|
const REPO = '/repo'
|
|
const FILE = 'src/file.ts'
|
|
const WORKING_TREE_PATH = `${REPO}/${FILE}`
|
|
const HEAD_PATH = `${REPO}/.git/HEAD`
|
|
const REF_PATH = `${REPO}/.git/refs/heads/main`
|
|
const INDEX_PATH = `${REPO}/.git/index`
|
|
const GITMODULES_PATH = `${REPO}/.gitmodules`
|
|
|
|
// Old enough that a further write is guaranteed to move the mtime, which is what
|
|
// lets the cache store at all.
|
|
const SETTLED_MTIME_MS = Date.now() - 60_000
|
|
let nextInode = 100
|
|
|
|
function writeFile(target: string, content: string, mtimeMs = SETTLED_MTIME_MS): void {
|
|
files.set(target, { content: Buffer.from(content), mtimeMs, ino: (nextInode += 1) })
|
|
}
|
|
|
|
function blobReadCount(): number {
|
|
return gitExecFileAsyncBufferMock.mock.calls.length
|
|
}
|
|
|
|
function seedRepo(): void {
|
|
files.clear()
|
|
// No `.git` file entry: `.git` is a directory, so reading it as a pointer misses.
|
|
writeFile(HEAD_PATH, 'ref: refs/heads/main\n')
|
|
writeFile(REF_PATH, `${'a'.repeat(40)}\n`)
|
|
writeFile(INDEX_PATH, 'index-bytes')
|
|
writeFile(WORKING_TREE_PATH, 'working-tree-content')
|
|
}
|
|
|
|
describe('settled diff cache', () => {
|
|
beforeEach(() => {
|
|
gitExecFileAsyncMock.mockReset()
|
|
gitExecFileAsyncBufferMock.mockReset()
|
|
gitStreamOptionsMock.mockReset()
|
|
readFileMock.mockClear()
|
|
statMock.mockClear()
|
|
accessMock.mockClear()
|
|
existsSyncMock.mockReset()
|
|
invalidateGitReadCaches()
|
|
settledDiffCache.resetStatsForTests()
|
|
seedRepo()
|
|
// `.gitmodules` is absent, so submodule routing resolves to "no submodules".
|
|
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
|
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('index-content\n') })
|
|
})
|
|
|
|
it('serves the second read of an unchanged file without respawning git', async () => {
|
|
const first = await getDiff(REPO, FILE, false)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
|
|
const second = await getDiff(REPO, FILE, false)
|
|
|
|
expect(spawnsAfterFirst).toBeGreaterThan(0)
|
|
expect(blobReadCount()).toBe(spawnsAfterFirst)
|
|
expect(second).toEqual(first)
|
|
expect(settledDiffCache.stats().hits).toBe(1)
|
|
})
|
|
|
|
// A WSL-routed read whose worktree path never went through UNC translation: git resolves
|
|
// `/home/...` inside the distro, but every Node read here has to be spelled for Win32.
|
|
describe('on a Windows host reading a raw Linux WSL worktree path', () => {
|
|
const WSL_WORKTREE = '/home/me/repo/feature'
|
|
const WSL_OPTIONS = { wslDistro: 'Ubuntu' }
|
|
const HOST_WORKTREE = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\feature`
|
|
let platformDescriptor: PropertyDescriptor | undefined
|
|
|
|
beforeEach(() => {
|
|
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
files.clear()
|
|
// `.git` is a directory here, so the gitdir is the worktree path plus a segment.
|
|
writeFile(path.join(HOST_WORKTREE, '.git/HEAD'), 'ref: refs/heads/main\n')
|
|
writeFile(path.join(HOST_WORKTREE, '.git/refs/heads/main'), `${'a'.repeat(40)}\n`)
|
|
writeFile(path.join(HOST_WORKTREE, '.git/index'), 'index-bytes')
|
|
writeFile(path.join(HOST_WORKTREE, FILE), 'working-tree-content')
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (platformDescriptor) {
|
|
Object.defineProperty(process, 'platform', platformDescriptor)
|
|
}
|
|
})
|
|
|
|
it('reads the working tree through the host spelling instead of reporting a deletion', async () => {
|
|
const result = await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS)
|
|
|
|
expect(result.modifiedContent).toBe('working-tree-content')
|
|
expect(readFileMock).toHaveBeenCalledWith(path.join(HOST_WORKTREE, FILE))
|
|
expect(readFileMock).not.toHaveBeenCalledWith(path.join(WSL_WORKTREE, FILE))
|
|
})
|
|
|
|
it('stamps through the host spelling so the second read does not respawn git', async () => {
|
|
await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
|
|
await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS)
|
|
|
|
expect(spawnsAfterFirst).toBeGreaterThan(0)
|
|
expect(blobReadCount()).toBe(spawnsAfterFirst)
|
|
expect(settledDiffCache.stats().hits).toBe(1)
|
|
expect(statMock).toHaveBeenCalledWith(path.join(HOST_WORKTREE, '.git/index'))
|
|
})
|
|
|
|
// Each stamp component has to be stat'd under the host spelling too: one that permanently
|
|
// misses is a constant, so the stamp stops moving and the cache serves a stale diff.
|
|
it('invalidates when the working tree file is edited under the host spelling', async () => {
|
|
await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
|
|
writeFile(path.join(HOST_WORKTREE, FILE), 'edited-in-another-editor')
|
|
const second = await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
expect(second.modifiedContent).toBe('edited-in-another-editor')
|
|
})
|
|
|
|
it('invalidates when .gitmodules appears under the host spelling', async () => {
|
|
await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
|
|
writeFile(path.join(HOST_WORKTREE, '.gitmodules'), '[submodule "vendor"]\n')
|
|
await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
expect(statMock).toHaveBeenCalledWith(path.join(HOST_WORKTREE, '.gitmodules'))
|
|
})
|
|
})
|
|
|
|
// An empty worktree path has no host spelling, and `path.join('', x)` is a *relative* path:
|
|
// every fs read would land in the process cwd, which in dev is Orca's own checkout.
|
|
it('reads nothing relative to the cwd when the worktree path has no host spelling', async () => {
|
|
await getDiff('', FILE, false)
|
|
|
|
expect(statMock).not.toHaveBeenCalledWith(FILE)
|
|
expect(readFileMock).not.toHaveBeenCalledWith('.git', 'utf-8')
|
|
expect(settledDiffCache.stats().entries).toBe(0)
|
|
})
|
|
|
|
// The four invalidation axes, one per diff input. Each proves the stale result is
|
|
// never served, which matters more than any of the hits above.
|
|
it.each([
|
|
[
|
|
'the working tree file is edited',
|
|
() => writeFile(WORKING_TREE_PATH, 'edited-in-another-editor')
|
|
],
|
|
['the index is rewritten by git add', () => writeFile(INDEX_PATH, 'index-bytes-after-add')],
|
|
['HEAD moves to a new commit', () => writeFile(REF_PATH, `${'b'.repeat(40)}\n`)],
|
|
['HEAD is detached onto another commit', () => writeFile(HEAD_PATH, `${'c'.repeat(40)}\n`)],
|
|
['.gitmodules appears', () => writeFile(GITMODULES_PATH, '[submodule "vendor"]\n')]
|
|
])('re-reads after %s', async (_name, mutate) => {
|
|
await getDiff(REPO, FILE, false)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
|
|
mutate()
|
|
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('fresh-index-content\n') })
|
|
const second = await getDiff(REPO, FILE, false)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
expect(second).toMatchObject({ originalContent: 'fresh-index-content\n' })
|
|
})
|
|
|
|
it('re-reads after a mutation runs through the shared invalidation point', async () => {
|
|
await getDiff(REPO, FILE, false)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
|
|
await stageFile(REPO, FILE)
|
|
await getDiff(REPO, FILE, false)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
})
|
|
|
|
// The dangerous ordering: the read observed pre-mutation state, so storing its
|
|
// result after the mutation would pin a diff that was already wrong.
|
|
it('refuses to store a result for a read that a mutation overtook', async () => {
|
|
let releaseBlob = (): void => {}
|
|
const blocked = new Promise<{ stdout: Buffer }>((resolve) => {
|
|
releaseBlob = () => resolve({ stdout: Buffer.from('pre-mutation\n') })
|
|
})
|
|
gitExecFileAsyncBufferMock.mockReturnValue(blocked)
|
|
|
|
const inFlight = getDiff(REPO, FILE, false)
|
|
await vi.waitFor(() => expect(blobReadCount()).toBeGreaterThan(0))
|
|
invalidateGitReadCaches()
|
|
releaseBlob()
|
|
await inFlight
|
|
|
|
expect(settledDiffCache.stats().invalidatedDuringRead).toBe(1)
|
|
expect(settledDiffCache.stats().entries).toBe(0)
|
|
|
|
const spawnsAfterFirst = blobReadCount()
|
|
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('post-mutation\n') })
|
|
const second = await getDiff(REPO, FILE, false)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
expect(second).toMatchObject({ originalContent: 'post-mutation\n' })
|
|
})
|
|
|
|
// The stamp is itself several awaited stats, so a mutation can begin and end entirely
|
|
// inside it — leaving a stamp torn across the mutation that no later stamp can match.
|
|
it('refuses to store a result for a mutation that landed inside the stamp read', async () => {
|
|
const baseStat = statMock.getMockImplementation()
|
|
if (!baseStat) {
|
|
throw new Error('the fake filesystem lost its stat implementation')
|
|
}
|
|
let invalidated = false
|
|
statMock.mockImplementation(async (target: string) => {
|
|
if (!invalidated && target === INDEX_PATH) {
|
|
invalidated = true
|
|
invalidateGitReadCaches()
|
|
}
|
|
return baseStat(target)
|
|
})
|
|
try {
|
|
await getDiff(REPO, FILE, false)
|
|
} finally {
|
|
statMock.mockImplementation(baseStat)
|
|
}
|
|
|
|
expect(invalidated).toBe(true)
|
|
expect(settledDiffCache.stats().invalidatedDuringRead).toBe(1)
|
|
expect(settledDiffCache.stats().entries).toBe(0)
|
|
})
|
|
|
|
// A write inside the mtime granularity window could be overwritten again without
|
|
// moving the timestamp, so that read is not allowed to become a cache entry.
|
|
it('refuses to store a diff of a file written moments ago', async () => {
|
|
writeFile(WORKING_TREE_PATH, 'just-saved', Date.now())
|
|
|
|
await getDiff(REPO, FILE, false)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
await getDiff(REPO, FILE, false)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
expect(settledDiffCache.stats().racyWrites).toBeGreaterThan(0)
|
|
expect(settledDiffCache.stats().entries).toBe(0)
|
|
})
|
|
|
|
// A folder workspace, or any path that is not a git checkout, cannot be stamped.
|
|
it('never caches when the repo layout cannot be stamped', async () => {
|
|
files.delete(HEAD_PATH)
|
|
|
|
await getDiff(REPO, FILE, false)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
await getDiff(REPO, FILE, false)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
expect(settledDiffCache.stats().unprovable).toBeGreaterThan(0)
|
|
expect(settledDiffCache.stats().entries).toBe(0)
|
|
})
|
|
|
|
// A WSL relay that never reached git returns the same empty left side as a new
|
|
// file does, so persisting it would pin a wrong diff until something else moved.
|
|
it('refuses to store a diff whose blob read failed rather than proved absence', async () => {
|
|
gitExecFileAsyncBufferMock.mockRejectedValue(
|
|
Object.assign(new Error('wsl.exe failed'), { code: 1 })
|
|
)
|
|
|
|
await getDiff(REPO, FILE, false)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
await getDiff(REPO, FILE, false)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst)
|
|
expect(settledDiffCache.stats().entries).toBe(0)
|
|
})
|
|
|
|
it('caches a new file whose absence from the index git actually reported', async () => {
|
|
gitExecFileAsyncBufferMock.mockRejectedValue(
|
|
Object.assign(new Error("fatal: path 'src/file.ts' does not exist"), { code: 128 })
|
|
)
|
|
|
|
await getDiff(REPO, FILE, false)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
await getDiff(REPO, FILE, false)
|
|
|
|
expect(blobReadCount()).toBe(spawnsAfterFirst)
|
|
expect(settledDiffCache.stats().hits).toBe(1)
|
|
})
|
|
|
|
it('keeps staged and unstaged diffs of one file in separate entries', async () => {
|
|
await getDiff(REPO, FILE, false)
|
|
const spawnsAfterUnstaged = blobReadCount()
|
|
|
|
await getDiff(REPO, FILE, true)
|
|
|
|
expect(blobReadCount()).toBeGreaterThan(spawnsAfterUnstaged)
|
|
})
|
|
|
|
// A staged diff compares HEAD to the index, so a working-tree edit must not evict it.
|
|
it('keeps a staged diff across a working-tree edit', async () => {
|
|
await getDiff(REPO, FILE, true)
|
|
const spawnsAfterFirst = blobReadCount()
|
|
|
|
writeFile(WORKING_TREE_PATH, 'edited-after-staging')
|
|
await getDiff(REPO, FILE, true)
|
|
|
|
expect(blobReadCount()).toBe(spawnsAfterFirst)
|
|
})
|
|
|
|
it('does not let a status poll drop the in-flight diff read', async () => {
|
|
let releaseBlob = (): void => {}
|
|
const blocked = new Promise<{ stdout: Buffer }>((resolve) => {
|
|
releaseBlob = () => resolve({ stdout: Buffer.from('index-content\n') })
|
|
})
|
|
gitExecFileAsyncBufferMock.mockReturnValue(blocked)
|
|
|
|
const first = getDiff(REPO, FILE, false)
|
|
await vi.waitFor(() => expect(blobReadCount()).toBeGreaterThan(0))
|
|
const spawnsBeforePoll = blobReadCount()
|
|
|
|
await getStatus(REPO)
|
|
const second = getDiff(REPO, FILE, false)
|
|
releaseBlob()
|
|
await Promise.all([first, second])
|
|
|
|
// Why exactly equal: the second read must join the first, not start its own spawns.
|
|
expect(blobReadCount()).toBe(spawnsBeforePoll)
|
|
})
|
|
})
|