import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WorktreeLogic from './ipc/worktree-logic' import type { Store } from './persistence' import { hasPendingStalePreparationCleanup } from './worktree-create-preparation-stale-cleanup' import type { Repo } from '../shared/repo-types' import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' const mocks = vi.hoisted(() => ({ mkdir: vi.fn(), listWorktreeGraph: vi.fn(), prepareCheckout: vi.fn(), finalize: vi.fn(), discard: vi.fn(), unlock: vi.fn(), getWorktreeOptions: vi.fn(), computeWorkspaceRoot: vi.fn(), computeWorkspaceRootAsync: vi.fn(), resolveBaseRef: vi.fn(), measureDivergence: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) vi.mock('./git/worktree-create-preparation', () => ({ prepareWorktreeCreateCheckout: mocks.prepareCheckout, finalizePreparedWorktree: mocks.finalize, discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: mocks.unlock })) vi.mock('./git/worktree-base-ref-probe', () => ({ resolveLocalWorktreeBaseRef: mocks.resolveBaseRef })) vi.mock('./git/worktree-base-divergence', () => ({ measureRetargetDivergence: mocks.measureDivergence })) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: () => undefined })) vi.mock('./ipc/worktree-logic', async (importOriginal) => ({ isOrphanedWorktreeError: (await importOriginal()).isOrphanedWorktreeError, computeWorkspaceRoot: mocks.computeWorkspaceRoot, computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, getWorktreePathSettings: () => ({ workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace', nestWorkspaces: false }) })) import { _resetWorktreeCreatePreparationsForTests, consumePreparedWorktreeCreate, hasPendingWorktreeCreatePreparations, prepareWorktreeCreateForRepo } from './worktree-create-preparation' // Evictions and retries are fire-and-forget, so let them settle before asserting. function flushBackgroundWork(ms = 0): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } const EXISTING_REFS = new Set([ 'refs/heads/main', 'refs/remotes/origin/main', 'refs/remotes/origin/release' ]) const repo = { id: 'repo-1', path: '/repo' } as Repo const store = { getSettings: () => ({}) } as unknown as Store beforeEach(() => { mocks.mkdir.mockReset().mockResolvedValue(undefined) mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) mocks.prepareCheckout.mockReset().mockResolvedValue(undefined) mocks.finalize.mockReset().mockResolvedValue({}) mocks.discard.mockReset().mockResolvedValue(undefined) mocks.unlock.mockReset().mockResolvedValue(undefined) mocks.getWorktreeOptions.mockReset().mockReturnValue({}) mocks.measureDivergence.mockReset().mockResolvedValue('within') mocks.resolveBaseRef .mockReset() .mockImplementation((_repoPath: string, baseRef: string) => resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) ) mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { throw new Error('synchronous workspace-root lookup must not run on the main thread') }) mocks.computeWorkspaceRootAsync .mockReset() .mockImplementation(async (repoPath: string) => process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath) ? 'C:\\workspace' : '/workspace' ) }) afterEach(async () => { await _resetWorktreeCreatePreparationsForTests() }) describe('worktree create preparation registry', () => { it.each([undefined, 'Ubuntu'])( 'preserves create priority through claim probes on %s', async (wslDistro) => { const routing = wslDistro ? { wslDistro } : {} mocks.getWorktreeOptions.mockReturnValue(routing) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') expect(mocks.resolveBaseRef).toHaveBeenLastCalledWith(repo.path, 'origin/main', routing) expect(mocks.prepareCheckout.mock.calls[0]?.[4]).not.toHaveProperty('admissionTier') const options = { ...routing, admissionTier: 'interactive' as const } await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'main', options }) ).resolves.toMatchObject({ status: 'hit', retargeted: true }) expect(mocks.resolveBaseRef).toHaveBeenLastCalledWith(repo.path, 'main', options) expect(mocks.measureDivergence).toHaveBeenCalledWith( repo.path, 'refs/remotes/origin/main', 'refs/heads/main', options ) } ) it('starts the checkout only once the async workspace root resolves', async () => { let resolveRoot!: (root: string) => void mocks.computeWorkspaceRootAsync.mockReturnValue( new Promise((resolve) => { resolveRoot = resolve }) ) const preparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main') await Promise.resolve() expect(mocks.prepareCheckout).not.toHaveBeenCalled() resolveRoot('/workspace') await preparation expect(mocks.computeWorkspaceRoot).not.toHaveBeenCalled() expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('still deduplicates when both callers await the same pending root lookup', async () => { let resolveRoot!: (root: string) => void mocks.computeWorkspaceRootAsync.mockReturnValue( new Promise((resolve) => { resolveRoot = resolve }) ) const first = prepareWorktreeCreateForRepo(store, repo, 'origin/main') const second = prepareWorktreeCreateForRepo(store, repo, 'origin/main') resolveRoot('/workspace') await Promise.all([first, second]) expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('namespaces native Windows preparation directories for long paths', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) try { await prepareWorktreeCreateForRepo(store, { ...repo, path: 'C:\\repo' }, 'origin/main') expect(mocks.mkdir).toHaveBeenCalledWith( expect.stringMatching(/^\\\\\?\\C:\\workspace\\\.orca-preparing/), { recursive: true } ) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) it('deduplicates preparation for the same repo, base, runtime, and workspace root', async () => { await Promise.all([ prepareWorktreeCreateForRepo(store, repo, 'origin/main'), prepareWorktreeCreateForRepo(store, repo, 'origin/main') ]) expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('does not claim a preparation after the selected base changes to another branch', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'origin/release' }) ).resolves.toEqual({ status: 'miss', reason: 'base_mismatch' }) expect(mocks.finalize).not.toHaveBeenCalled() }) it('claims across the local/remote spelling of the same base and reports the retarget', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') // `main` has no local ref here, so the canonical forms differ and only the base family matches. await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'main' }) ).resolves.toEqual({ status: 'hit', retargeted: true, result: {}, rearm: expect.any(Function) }) // Finalize still receives the requested base, so it resets onto the requested commit. expect(mocks.finalize).toHaveBeenCalledWith( repo.path, expect.any(String), '/workspace/final', 'feature/test', 'main', undefined, {} ) }) it('refuses a same-family retarget whose bases have drifted too far apart', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') // An abandoned fork's `main` is the same base family but a whole-tree checkout away. mocks.measureDivergence.mockResolvedValue('exceeded') await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'main' }) ).resolves.toEqual({ status: 'miss', reason: 'retarget_too_divergent' }) expect(mocks.finalize).not.toHaveBeenCalled() // The preparation is left armed for the base it actually holds. expect(mocks.discard).not.toHaveBeenCalled() }) it('separates a drift check that said no from one that could not answer', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') // A timed-out or aborted walk skipped a retarget that may well have been cheap; that is a // tuning signal, not the bound working as intended, so it must not report as excess drift. mocks.measureDivergence.mockResolvedValue('unknown') await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'main' }) ).resolves.toEqual({ status: 'miss', reason: 'retarget_unverifiable' }) expect(mocks.finalize).not.toHaveBeenCalled() expect(mocks.discard).not.toHaveBeenCalled() }) it('does not spend a divergence walk when the base matches exactly', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'origin/main' }) expect(mocks.measureDivergence).not.toHaveBeenCalled() }) it('claims when the two sides spell the same ref differently', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'refs/remotes/origin/main' }) ).resolves.toEqual({ status: 'hit', retargeted: false, result: {}, rearm: expect.any(Function) }) }) it('never hands the same prepared checkout to two concurrent creates', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') // `main` needs the ref probe, so the claim has to await mid-flight — the window where a // second create could otherwise walk away with the same preparation. const [first, second] = await Promise.all([ consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/first', branch: 'feature/first', baseBranch: 'main' }), consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/second', branch: 'feature/second', baseBranch: 'main' }) ]) expect([first.status, second.status]).toContain('hit') const preparedPaths = mocks.finalize.mock.calls.map((call) => call[1]) expect(new Set(preparedPaths).size).toBe(preparedPaths.length) }) it('reports which part of the claim key disagreed', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/other-workspace', worktreePath: '/other-workspace/final', branch: 'feature/test', baseBranch: 'origin/main' }) ).resolves.toEqual({ status: 'miss', reason: 'workspace_root_mismatch' }) await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'origin/main', options: { wslDistro: 'Ubuntu' } }) ).resolves.toEqual({ status: 'miss', reason: 'wsl_distro_mismatch' }) await expect( consumePreparedWorktreeCreate({ repoPath: '/other-repo', workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'origin/main' }) ).resolves.toEqual({ status: 'miss', reason: 'repo_mismatch' }) expect(mocks.finalize).not.toHaveBeenCalled() }) it("evicts a repo's own stale preparation before another repo's", async () => { const otherRepo = { id: 'repo-2', path: '/other-repo' } as Repo await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') // Fill the pool from one repo, as flipping the composer's base picker does, until the next // arm has to evict something. await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await prepareWorktreeCreateForRepo(store, repo, 'origin/release') await prepareWorktreeCreateForRepo(store, repo, 'main') // The eviction must cost `repo` a slot, not `otherRepo` its warm checkout. await expect( consumePreparedWorktreeCreate({ repoPath: otherRepo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/other', branch: 'feature/other', baseBranch: 'origin/main' }) ).resolves.toMatchObject({ status: 'hit' }) }) it('routes preparation and finalization through the selected WSL runtime', async () => { const options = { wslDistro: 'Ubuntu' } mocks.getWorktreeOptions.mockReturnValue(options) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'origin/main', options }) expect(mocks.prepareCheckout).toHaveBeenCalledWith( repo.path, expect.any(String), 'refs/remotes/origin/main', expect.any(String), { ...options, signal: expect.any(AbortSignal) } ) expect(mocks.finalize).toHaveBeenCalledWith( repo.path, expect.any(String), '/workspace/final', 'feature/test', 'origin/main', undefined, options ) }) it('retries stale cleanup after a transient listing failure', async () => { mocks.listWorktreeGraph.mockRejectedValueOnce(new Error('temporary listing failure')) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await prepareWorktreeCreateForRepo(store, repo, 'origin/release') expect(mocks.listWorktreeGraph).toHaveBeenCalledTimes(2) }) it('prepares while stale removal is stalled, shares its scan, and settles removal on reset', async () => { const stalePath = '/workspace/.orca-preparing/999999999-11111111-1111-4111-8111-111111111111' let releaseRemoval!: () => void const removal = new Promise((resolve) => { releaseRemoval = resolve }) mocks.listWorktreeGraph.mockResolvedValueOnce([ { path: stalePath, branch: undefined, lockReason: 'orca-create-preparation:v1:999999999:stale', head: 'deadbeef', isBare: false, isMainWorktree: false } ]) mocks.discard.mockImplementation((_repo, path) => path === stalePath ? removal : Promise.resolve() ) let ready = false let reset: Promise | undefined const preparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main').then(() => { ready = true }) try { await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, stalePath, { admissionTier: 'background' }) expect(ready).toBe(true) await prepareWorktreeCreateForRepo(store, repo, 'origin/release') expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) expect(mocks.listWorktreeGraph).toHaveBeenCalledTimes(1) expect(hasPendingStalePreparationCleanup()).toBe(true) mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) expect(mocks.listWorktreeGraph).toHaveBeenCalledTimes(2) expect(mocks.listWorktreeGraph).toHaveBeenLastCalledWith(repo.path, { wslDistro: 'Ubuntu', includeCreatePreparations: true }) let resetFinished = false reset = _resetWorktreeCreatePreparationsForTests().then(() => { resetFinished = true }) await flushBackgroundWork() expect(resetFinished).toBe(false) } finally { releaseRemoval() await preparation await reset } expect(hasPendingStalePreparationCleanup()).toBe(false) }) it('unlocks a stale branch-attached final path instead of deleting user work', async () => { mocks.listWorktreeGraph.mockResolvedValueOnce([ { path: '/workspace/final', branch: 'refs/heads/feature/test', lockReason: 'orca-create-preparation:v1:999999999:stale', head: 'deadbeef', isBare: false, isMainWorktree: false } ]) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') expect(mocks.unlock).toHaveBeenCalledWith(repo.path, '/workspace/final', { admissionTier: 'background' }) expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, '/workspace/final', expect.anything()) }) it('does not classify a user branch worktree under the preparation directory as stale', async () => { mocks.listWorktreeGraph.mockResolvedValueOnce([ { path: '/workspace/.orca-preparing/999999999-user-worktree', branch: 'refs/heads/user-worktree', lockReason: undefined, head: 'deadbeef', isBare: false, isMainWorktree: false } ]) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') expect(mocks.unlock).not.toHaveBeenCalled() expect(mocks.discard).not.toHaveBeenCalled() }) it('does not discard a detached worktree with caller-controlled preparation metadata', async () => { mocks.listWorktreeGraph.mockResolvedValueOnce([ { path: `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/999-checkout`, branch: undefined, lockReason: 'orca-create-preparation:v1:999999999:spoofed', head: 'deadbeef', isBare: false, isMainWorktree: false } ]) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') expect(mocks.discard).not.toHaveBeenCalledWith( repo.path, `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/999-checkout`, {} ) }) it('cleans up and reports a finalize miss so normal add can run', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/final', branch: 'feature/test', baseBranch: 'origin/main' }) ).resolves.toMatchObject({ status: 'miss', reason: 'finalize_failed' }) expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) expect(mocks.discard).toHaveBeenCalledTimes(1) }) /** Mirrors a real create: consume, then run the deferred re-arm once the create has returned. */ async function consumeOnce(name: string): Promise { const attempt = await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: `/workspace/${name}`, branch: `feature/${name}`, baseBranch: 'origin/main' }) if (attempt.status === 'hit') { attempt.rearm() } } it('does not re-arm after an isolated create', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('only') // Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL. expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('re-arms a preparation once creates arrive in a burst', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('first') expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('second') await prepareWorktreeCreateForRepo(store, repo, 'origin/main') expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) // The replacement is claimable, so a third create still skips the cold add. await expect( consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/third', branch: 'feature/third', baseBranch: 'origin/main' }) ).resolves.toEqual({ status: 'hit', retargeted: false, result: {}, rearm: expect.any(Function) }) expect(mocks.finalize).toHaveBeenCalledTimes(3) }) it('holds the re-arm checkout until the create runs the deferred thunk', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('first') await prepareWorktreeCreateForRepo(store, repo, 'origin/main') mocks.prepareCheckout.mockClear() const attempt = await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/second', branch: 'feature/second', baseBranch: 'origin/main' }) // Drained first: an eager re-arm reaches prepareCheckout only after the pool awaits stale // cleanup, so asserting in the same turn would pass with the deferral removed. await flushBackgroundWork() // The replacement checkout would otherwise hold a git admission slot for the rest of the create. expect(mocks.prepareCheckout).not.toHaveBeenCalled() expect(attempt.status).toBe('hit') if (attempt.status === 'hit') { attempt.rearm() } await flushBackgroundWork() expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('starts one explicit prefetch after the create completes', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('first') await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const attempt = await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot: '/workspace', worktreePath: '/workspace/second', branch: 'feature/second', baseBranch: 'origin/main' }) expect(attempt.status).toBe('hit') // The user reopens the composer while the create is still finishing. await prepareWorktreeCreateForRepo(store, repo, 'origin/main') expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) mocks.prepareCheckout.mockClear() if (attempt.status === 'hit') { attempt.rearm() } await flushBackgroundWork() expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('does not re-arm when finalization failed', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('first') await prepareWorktreeCreateForRepo(store, repo, 'origin/main') mocks.prepareCheckout.mockClear() mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) await consumeOnce('second') expect(mocks.prepareCheckout).not.toHaveBeenCalled() }) it('retries a discard that failed while this process is still alive', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) // Fill the registry so the first preparation is evicted while its owner pid is still alive. for (const base of ['origin/one', 'origin/two', 'origin/three']) { await prepareWorktreeCreateForRepo(store, repo, base) } await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) mocks.discard.mockClear() await prepareWorktreeCreateForRepo(store, repo, 'origin/four') await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) mocks.discard.mockClear() await prepareWorktreeCreateForRepo(store, repo, 'origin/five') await flushBackgroundWork() expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) }) it('retries only the leaked paths belonging to the host being prepared', async () => { const otherRepo = { ...repo, id: 'repo-2', path: '/other-repo' } as Repo const unremovable = new Set() mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { if (unremovable.has(path)) { throw new Error('EBUSY') } }) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedHere = mocks.prepareCheckout.mock.calls[0][1] as string await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') const leakedElsewhere = mocks.prepareCheckout.mock.calls[1][1] as string unremovable.add(leakedHere) unremovable.add(leakedElsewhere) // Evict through each host's own arming: eviction prefers the incoming workspace's oldest // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. for (const base of ['origin/one', 'origin/two']) { await prepareWorktreeCreateForRepo(store, repo, base) } for (const base of ['origin/one', 'origin/two']) { await prepareWorktreeCreateForRepo(store, otherRepo, base) } await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) expect(mocks.discard).toHaveBeenCalledWith(otherRepo.path, leakedElsewhere, {}) mocks.discard.mockClear() await prepareWorktreeCreateForRepo(store, repo, 'origin/four') await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) expect(mocks.discard.mock.calls.some((call) => call[1] === leakedElsewhere)).toBe(false) }) it('stops retrying a preparation that never becomes removable', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { if (path === leakedPath) { throw new Error('EBUSY') } }) const leakedDiscards = (): number => mocks.discard.mock.calls.filter((call) => call[1] === leakedPath).length for (const base of ['origin/one', 'origin/two', 'origin/three']) { await prepareWorktreeCreateForRepo(store, repo, base) } await flushBackgroundWork() expect(leakedDiscards()).toBe(1) for (const base of ['origin/four', 'origin/five', 'origin/six']) { await prepareWorktreeCreateForRepo(store, repo, base) await flushBackgroundWork() } expect(leakedDiscards()).toBe(3) expect(warn).toHaveBeenCalledWith( expect.stringContaining(`could not be discarded in 3 attempts; ${leakedPath}`), expect.any(Error) ) } finally { warn.mockRestore() } }) it('retries a failed checkout whose own self-discard also left the path registered', async () => { let failCheckout!: (error: Error) => void mocks.prepareCheckout.mockImplementationOnce( () => new Promise((_resolve, reject) => { failCheckout = reject }) ) const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') await flushBackgroundWork() const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string // Evict it while its checkout is still in flight, so discardEntry runs on a failed preparation. for (const base of ['origin/one', 'origin/two', 'origin/three']) { await prepareWorktreeCreateForRepo(store, repo, base) } mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) failCheckout(new Error('worktree add failed')) await failing.catch(() => {}) await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) mocks.discard.mockClear() await prepareWorktreeCreateForRepo(store, repo, 'origin/four') await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) }) it('scopes retries to the WSL distro whose preparation leaked', async () => { const unremovable = new Set() mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { if (unremovable.has(path)) { throw new Error('EBUSY') } }) mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedOnUbuntu = mocks.prepareCheckout.mock.calls[0][1] as string mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedOnDebian = mocks.prepareCheckout.mock.calls[1][1] as string unremovable.add(leakedOnUbuntu) unremovable.add(leakedOnDebian) // Evict through each distro's own arming: the eviction scope includes the distro, so arming // under Ubuntu no longer reaches across and takes the Debian entry. mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) for (const base of ['origin/one', 'origin/two']) { await prepareWorktreeCreateForRepo(store, repo, base) } mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) await prepareWorktreeCreateForRepo(store, repo, 'origin/one') mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnDebian, { wslDistro: 'Debian' }) mocks.discard.mockClear() await prepareWorktreeCreateForRepo(store, repo, 'origin/four') await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) expect(mocks.discard.mock.calls.some((call) => call[1] === leakedOnDebian)).toBe(false) }) it('drops recorded discards when the registry is reset for tests', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string // Reject on a real timer so the fire-and-forget discard is still in flight at reset. mocks.discard.mockImplementationOnce(async () => { await new Promise((resolve) => setTimeout(resolve, 5)) throw new Error('EBUSY') }) for (const base of ['origin/one', 'origin/two', 'origin/three']) { await prepareWorktreeCreateForRepo(store, repo, base) } await _resetWorktreeCreatePreparationsForTests() // Past the rejection timer: the reset must have absorbed the failure, not raced ahead of it. await flushBackgroundWork(20) mocks.discard.mockClear() await prepareWorktreeCreateForRepo(store, repo, 'origin/four') await flushBackgroundWork() expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) }) it("settles an evicted preparation's discard before the reset drops the registry", async () => { let failCheckout!: (error: Error) => void mocks.prepareCheckout.mockImplementationOnce( () => new Promise((_resolve, reject) => { failCheckout = reject }) ) const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') await flushBackgroundWork() const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { if (path === leakedPath) { throw new Error('EBUSY') } }) for (const base of ['origin/one', 'origin/two', 'origin/three']) { await prepareWorktreeCreateForRepo(store, repo, base) } // The eviction's discard is still parked on the checkout, so the reset has to wait for it. const reset = _resetWorktreeCreatePreparationsForTests() await flushBackgroundWork(5) failCheckout(new Error('worktree add failed')) await failing.catch(() => {}) await reset await flushBackgroundWork(5) mocks.discard.mockClear() await prepareWorktreeCreateForRepo(store, repo, 'origin/four') await flushBackgroundWork() expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) }) it('reports a pending create while a stale-cleanup scan is running', async () => { let releaseListing!: () => void mocks.listWorktreeGraph.mockReturnValueOnce( new Promise((resolve) => { releaseListing = () => resolve([]) }) ) const arming = prepareWorktreeCreateForRepo(store, repo, 'origin/main') // Anchor on the scan actually starting, not on a fixed number of microtasks: an await added // ahead of it would otherwise make this pass vacuously rather than fail. while (mocks.listWorktreeGraph.mock.calls.length === 0) { await Promise.resolve() } // Why: the idle gate must not start repo maintenance while crash recovery is mid-scan. expect(hasPendingWorktreeCreatePreparations()).toBe(true) releaseListing() await arming }) })