Files
orca/src/main/git/worktree-sparse-checkout-cache.test.ts
T
OrcaWinandm4air 4085e1cf60 fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries

* fix(memory): bound transient filesystem registries

* fix(memory): cap path and locale caches

* fix(memory): bound runtime recovery registries

* fix(memory): bound host mirror gap verdicts

* fix(memory): bound shell startup env cache

* fix(memory): bound gitlab host context cache

* fix(memory): release removed ssh generations

* fix(memory): expire cloud refresh replay guards

* fix(memory): release retired plugin generations

* fix(memory): bound plugin log key retention

* fix(memory): bound automation authority generations

* fix(memory): bound native chat enrichment cache

* fix(memory): bound web session tracking generations

* fix(memory): bound codex credential absence paths

* fix(memory): bound WSL canonical path cache

* fix(memory): bound sparse checkout cache

* fix(memory): bound shared directory cache

* fix(memory): bound advertised URL scan snapshots

* fix(memory): bound automation manager cache

* fix(memory): bound web session reorder intents

* fix(memory): bound web session focus intents

* fix(memory): bound web session handoffs

* fix(memory): bound automation dispatch tokens

* fix(memory): bound host mirror waiters

* fix(memory): bound retained session activity

* fix(memory): bound retained session activity

* fix(memory): bound web session close intents

* fix(memory): bound cloud session cache

* fix(memory): bound WSL home cache

* fix(memory): bound SSH capability cache

* fix(memory): bound trust grant cooldowns

* fix(memory): bound WSL auth drain state

* fix(memory): bound Linear workspace credential cache

* fix(memory): bound local Git capability cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL preflight cache

* fix(memory): keep hot cache entries warm

* fix(memory): preserve generation fences across eviction

* fix(memory): close remaining eviction fences

* fix(memory): align evicted upstream generations

* fix(memory): trim successful capability probes

* fix(auth): retain expired refresh replay evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-20 14:41:50 -07:00

368 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { detectSparseCheckoutMock } = vi.hoisted(() => ({
detectSparseCheckoutMock: vi.fn()
}))
vi.mock('./worktree-sparse-state', () => ({
detectSparseCheckout: detectSparseCheckoutMock,
resolveGitCommonDir: vi.fn()
}))
import {
__getSparseCheckoutStateCacheSizeForTests,
__resetSparseCheckoutStateCacheForTests,
clearSparseCheckoutStateCache,
clearSparseCheckoutStateCacheForRepo,
detectSparseCheckoutCached,
invalidateSparseCheckoutState,
MAX_SPARSE_CHECKOUT_CACHE_ENTRIES,
onSparseCheckoutStateChanged
} from './worktree-sparse-checkout-cache'
const RECONCILE_WINDOW_MS = 5 * 60_000
// A real setTimeout tick, not a faked one, to flush the microtask chain a background
// stale-while-revalidate detect runs on without needing vi.useFakeTimers() (which would also
// have to fake Date.now(), the thing these tests drive manually via the Date.now spy below).
async function flushBackgroundRevalidation(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
detectSparseCheckoutMock.mockReset()
__resetSparseCheckoutStateCacheForTests()
})
describe('detectSparseCheckoutCached', () => {
it('bounds cache growth when worktree paths churn', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
for (let index = 0; index < MAX_SPARSE_CHECKOUT_CACHE_ENTRIES + 4; index += 1) {
await detectSparseCheckoutCached('/repo', `/repo/worktree-${index}`)
}
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(MAX_SPARSE_CHECKOUT_CACHE_ENTRIES)
})
it('caches a detection result across repeated calls for the same repo+path', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
})
it('detects each distinct worktree path independently', async () => {
detectSparseCheckoutMock.mockImplementation(
async (worktreePath: string) => worktreePath === '/repo/wt-sparse'
)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-sparse')).toBe(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-full')).toBe(false)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2)
})
it('scopes the cache by repo, so the same path under two repos is detected independently', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
expect(await detectSparseCheckoutCached('/repo-a', '/shared-mount/wt')).toBe(true)
expect(await detectSparseCheckoutCached('/repo-b', '/shared-mount/wt')).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2)
})
it('treats an equivalent path spelling (trailing slash, redundant segment) as the same cache entry', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/./wt-a/')).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
})
it('caches a false result too, so a worktree that stays non-sparse costs one detect', async () => {
detectSparseCheckoutMock.mockResolvedValue(false)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
})
it('serves the stale value immediately past the reconcile window and corrects it in the background', async () => {
const nowSpy = vi.spyOn(Date, 'now')
try {
nowSpy.mockReturnValue(1_000)
detectSparseCheckoutMock.mockResolvedValueOnce(false)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false)
// Just under the window: still trusts the cached value, no re-detect.
nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS - 1)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
// Past the window: both calls return the (stale) cached value, and only one kicks a
// background re-detect. Read both without awaiting in between — the underlying function
// never suspends before returning the stale value, so two calls issued back-to-back in the
// same tick are the only reliable way to observe "both concurrent callers stay stale" without
// racing the background revalidation's own microtask.
nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1)
detectSparseCheckoutMock.mockResolvedValueOnce(true)
const firstPastWindow = detectSparseCheckoutCached('/repo', '/repo/wt-a')
const secondPastWindow = detectSparseCheckoutCached('/repo', '/repo/wt-a')
expect(await firstPastWindow).toBe(false)
expect(await secondPastWindow).toBe(false)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2)
await flushBackgroundRevalidation()
// The corrected value is now served without needing another window to elapse.
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2)
} finally {
nowSpy.mockRestore()
}
})
it('does not resurrect an entry that was explicitly invalidated while a background revalidation was in flight', async () => {
const nowSpy = vi.spyOn(Date, 'now')
let resolveDetect: (isSparse: boolean) => void = () => {}
try {
nowSpy.mockReturnValue(1_000)
detectSparseCheckoutMock.mockResolvedValueOnce(false)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
// Past the window: kicks a background re-detect that we hold open.
nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1)
detectSparseCheckoutMock.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (resolveDetect = resolve))
)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
// The worktree is removed (or the repo cache is cleared) while the detect above is in flight.
invalidateSparseCheckoutState('/repo', '/repo/wt-a')
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0)
// The in-flight detect now resolves; it must not write the entry back.
resolveDetect(true)
await flushBackgroundRevalidation()
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0)
} finally {
nowSpy.mockRestore()
}
})
it('does not let a stale in-flight revalidation clobber a fresh value written after remove+recreate at the same path', async () => {
const nowSpy = vi.spyOn(Date, 'now')
let resolveStaleDetect: (isSparse: boolean) => void = () => {}
try {
nowSpy.mockReturnValue(1_000)
detectSparseCheckoutMock.mockResolvedValueOnce(false)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
// Past the window: kicks a background re-detect that we hold open (simulates a slow probe
// racing a worktree removal + recreation at the same path).
nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1)
detectSparseCheckoutMock.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (resolveStaleDetect = resolve))
)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
// The worktree is removed (invalidate) and a new one is recreated at the exact same path,
// repopulating the key with a fresh, different value via a normal cold read.
invalidateSparseCheckoutState('/repo', '/repo/wt-a')
detectSparseCheckoutMock.mockResolvedValueOnce(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true)
// The stale in-flight detect from before the remove+recreate now resolves with the old
// answer. A presence-only guard would let this overwrite the fresh entry above; the fix
// must compare entry identity and refuse to write back over a value it didn't produce.
resolveStaleDetect(false)
await flushBackgroundRevalidation()
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true)
} finally {
nowSpy.mockRestore()
}
})
it('notifies the registered change listener only when a background revalidation flips the answer', async () => {
const listener = vi.fn()
onSparseCheckoutStateChanged(listener)
const nowSpy = vi.spyOn(Date, 'now')
try {
nowSpy.mockReturnValue(1_000)
detectSparseCheckoutMock.mockResolvedValueOnce(false)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1)
detectSparseCheckoutMock.mockResolvedValueOnce(false)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
await flushBackgroundRevalidation()
expect(listener).not.toHaveBeenCalled()
nowSpy.mockReturnValue(1_000 + 2 * RECONCILE_WINDOW_MS + 2)
detectSparseCheckoutMock.mockResolvedValueOnce(true)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
await flushBackgroundRevalidation()
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith('/repo', '/repo/wt-a', true)
} finally {
nowSpy.mockRestore()
onSparseCheckoutStateChanged(undefined)
}
})
})
describe('invalidateSparseCheckoutState', () => {
it('drops only the named repo+path, leaving other cached paths untouched', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
await detectSparseCheckoutCached('/repo', '/repo/wt-b')
invalidateSparseCheckoutState('/repo', '/repo/wt-a')
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1)
detectSparseCheckoutMock.mockClear()
await detectSparseCheckoutCached('/repo', '/repo/wt-a')
await detectSparseCheckoutCached('/repo', '/repo/wt-b')
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
expect(detectSparseCheckoutMock).toHaveBeenCalledWith('/repo/wt-a', {})
})
})
describe('clearSparseCheckoutStateCacheForRepo', () => {
it('drops only the named repo`s entries, leaving a sibling repo`s warm cache intact', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
await detectSparseCheckoutCached('/repo-a', '/repo-a/wt-1')
await detectSparseCheckoutCached('/repo-b', '/repo-b/wt-1')
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(2)
clearSparseCheckoutStateCacheForRepo('/repo-a')
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1)
detectSparseCheckoutMock.mockClear()
await detectSparseCheckoutCached('/repo-a', '/repo-a/wt-1')
await detectSparseCheckoutCached('/repo-b', '/repo-b/wt-1')
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
})
})
describe('clearSparseCheckoutStateCache', () => {
it('drops every cached path across every repo, matching the fallback used when a repo cannot be resolved', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
await detectSparseCheckoutCached('/repo-a', '/repo-a/wt-1')
await detectSparseCheckoutCached('/repo-b', '/repo-b/wt-1')
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(2)
clearSparseCheckoutStateCache()
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0)
})
})
// Regression coverage for the live Windows+WSL sequence: a distro-less listing (filesystem-auth
// root rebuild, worktree ownership checks) racing the real distro-carrying listing for the same
// repo. Before the distro joined the cache key they shared one entry, so whichever ran first
// decided the sparse badge for the whole reconcile window.
describe('detectSparseCheckoutCached with a WSL distro', () => {
// Mirrors the real probe: without the distro the gitdir pointer resolves to a fabricated Win32
// path, the sparse-checkout stat misses, and the worktree reads as non-sparse.
function detectOnlyWithDistro(distro: string): void {
detectSparseCheckoutMock.mockImplementation(
async (_worktreePath: string, options?: { wslDistro?: string }) =>
options?.wslDistro === distro
)
}
it('does not serve a distro-carrying read an answer derived without that distro', async () => {
detectOnlyWithDistro('Ubuntu')
expect(await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')).toBe(false)
expect(
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' })
).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2)
})
it('keeps a distro-carrying answer correct when a distro-less read follows it', async () => {
detectOnlyWithDistro('Ubuntu')
expect(
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' })
).toBe(true)
expect(await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')).toBe(false)
expect(
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' })
).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2)
})
it('treats distro spellings that name the same distro as one entry', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
expect(
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' })
).toBe(true)
expect(
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: ' ubuntu ' })
).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
})
it('does not let a distro-less reader past the window revalidate a distro-carrying entry', async () => {
const listener = vi.fn()
onSparseCheckoutStateChanged(listener)
const nowSpy = vi.spyOn(Date, 'now')
try {
detectOnlyWithDistro('Ubuntu')
nowSpy.mockReturnValue(1_000)
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' })
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')
// Past the window the distro-less caller re-probes its own entry, not the sparse one, so the
// badge cannot blink off and fire the change listener that clears the whole repo's cache.
nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1)
expect(await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')).toBe(false)
await flushBackgroundRevalidation()
expect(listener).not.toHaveBeenCalled()
expect(
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' })
).toBe(true)
} finally {
nowSpy.mockRestore()
onSparseCheckoutStateChanged(undefined)
}
})
it('drops every distro variant of a path on invalidate, so a removed worktree leaves nothing behind', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' })
await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\other')
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(3)
invalidateSparseCheckoutState('C:\\repo', 'C:\\repo\\wt')
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1)
})
it('still caches normally with no distro anywhere, as on macOS/Linux and native Windows', async () => {
detectSparseCheckoutMock.mockResolvedValue(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a', {})).toBe(true)
expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a', { wslDistro: undefined })).toBe(
true
)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1)
})
})