diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 5827eb65391..c58cdc42d39 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as filesystemAuth from './filesystem-auth' +import * as authorizedRootsCache from './registered-worktree-roots-cache' import * as localWorktreeFilesystem from '../local-worktree-filesystem' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { RedactableSpan } from '../observability/redactor' +import type { Store } from '../persistence' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' import { agentHookServer } from '../agent-hooks/server' import { makePaneKey } from '../../shared/stable-pane-id' @@ -26,7 +29,13 @@ import { getLocalPtyProviderMock, getSshPtyProviderMock } from './worktrees-test-module-mocks' -import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { + handlers, + harnessRepo, + mainWindow, + setupWorktreeHandlers, + store +} from './worktrees-test-harness' import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' @@ -120,15 +129,47 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { runtimeStub = setupWorktreeHandlers() + vi.spyOn(filesystemAuth, 'invalidateAuthorizedRootsCacheForRepo').mockClear() + vi.spyOn(authorizedRootsCache, 'invalidateAuthorizedRootsCache').mockClear() }) it('prunes the persisted cleanup and space snapshots on removal', async () => { mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) removeWorktreeMock.mockResolvedValue({}) + const unrelatedRepo = { ...harnessRepo, id: 'repo-2', path: '/unrelated/repo' } + store.getRepos.mockReturnValue([harnessRepo, unrelatedRepo]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this existing IPC store fixture supplies the repository, project, folder, and settings methods the root registry reads. + const cacheStore = store as unknown as Store + authorizedRootsCache.registerWorktreeRootsForRepo(cacheStore, 'repo-1', [ + '/workspace/feature-wt' + ]) + authorizedRootsCache.registerWorktreeRootsForRepo(cacheStore, 'repo-2', ['/unrelated/worktree']) + const removedRevision = authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-1') + const unrelatedRevision = authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-2') await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + expect(authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-1')).toBeGreaterThan( + removedRevision + ) + expect(authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-2')).toBe( + unrelatedRevision + ) + expect(authorizedRootsCache.isRegisteredWorktreePath('/workspace/feature-wt', cacheStore)).toBe( + false + ) + expect(authorizedRootsCache.isRegisteredWorktreePath('/unrelated/worktree', cacheStore)).toBe( + true + ) + + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith( + store, + 'repo-1' + ) + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() + // A removed workspace must never resurrect from the cached scan snapshots. expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith( '/profile-a', @@ -142,6 +183,22 @@ describe('registerWorktreeHandlers', () => { ) }) + it('globally invalidates when the removed repo has no registered authorization owner', async () => { + mockKnownFeatureWorktree() + store.getRepos.mockReturnValue([]) + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith( + store, + 'repo-1' + ) + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).toHaveBeenCalledOnce() + }) + it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => { const worktreeId = 'repo-1::/workspace/feature-wt' const localRepo = { @@ -290,6 +347,13 @@ describe('registerWorktreeHandlers', () => { worktreeId: 'repo-1::/workspace/feature-wt' }) + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith( + store, + 'repo-1' + ) + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() + // Should have called git worktree prune to clean up stale tracking expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' diff --git a/src/main/ipc/worktrees-remove-preflight.test.ts b/src/main/ipc/worktrees-remove-preflight.test.ts index 335eeda6e90..39d6758291f 100644 --- a/src/main/ipc/worktrees-remove-preflight.test.ts +++ b/src/main/ipc/worktrees-remove-preflight.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as filesystemAuth from './filesystem-auth' +import * as authorizedRootsCache from './registered-worktree-roots-cache' import { removeWorktreeLinkedPathsMock, findExistingWorktreeSymlinkPathsMock, @@ -104,6 +106,8 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { runtimeStub = setupWorktreeHandlers() + vi.spyOn(filesystemAuth, 'invalidateAuthorizedRootsCacheForRepo').mockClear() + vi.spyOn(authorizedRootsCache, 'invalidateAuthorizedRootsCache').mockClear() }) it('fails dirty non-force deletes before PTY teardown', async () => { @@ -139,6 +143,8 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).not.toHaveBeenCalled() + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() }) it('propagates a timed-out removal preflight before watcher teardown', async () => { @@ -200,6 +206,8 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).not.toHaveBeenCalled() + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() }) it('rechecks a local Git lock after the archive hook before teardown', async () => { diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index 5a27d987a78..52d8f26ee24 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -22,7 +22,7 @@ import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from '../../worktree-symlinks' -import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { invalidateAuthorizedRootsCacheForRepo } from '../../filesystem-auth' import { runWorktreeChangeInvalidators } from '../../worktree-change-invalidators' import { formatWorktreeRemovalError, @@ -276,7 +276,7 @@ async function finishLocalWorktreeRemoval({ hostId: removalHostId }) ) - invalidateAuthorizedRootsCache() + invalidateAuthorizedRootsCacheForRepo(store, repoId) removalCompleted = true return {} } else { @@ -315,7 +315,7 @@ async function finishLocalWorktreeRemoval({ ) }) await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { - invalidateAuthorizedRootsCache() + invalidateAuthorizedRootsCacheForRepo(store, repoId) }) return removalResult ?? {} }