diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index aed03724200..db50b853b0a 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -6,6 +6,7 @@ const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') return { reposMocks: moduleMocks.createReposIpcMocks(), moduleMocks } }) +const scheduleWorktreeWatcherSyncMock = vi.hoisted(() => vi.fn()) vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => @@ -21,6 +22,9 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) +vi.mock('./worktree-base-directory-watcher', () => ({ + scheduleCurrentWorktreeBaseDirectoryWatcherSync: scheduleWorktreeWatcherSyncMock +})) vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) vi.mock('../providers/ssh-filesystem-dispatch', () => moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) @@ -54,6 +58,7 @@ describe('repos:add + repos:clone', () => { captureHandlers(handleMock) resetLocalRepoMocks(reposMocks) mockWindow.webContents.send.mockReset() + scheduleWorktreeWatcherSyncMock.mockClear() registerRepoHandlers(mockWindow as never, mockStore as never) }) @@ -193,16 +198,26 @@ describe('repos:add + repos:clone', () => { mockStore.getProjects.mockReturnValue([project]) mockStore.getProjectHostSetups.mockReturnValue([setup]) mockStore.updateRepo.mockReturnValue(aligned) + const preparation = Promise.withResolvers() + prepareLocalWorktreeRootForRepoMock.mockReturnValueOnce(preparation.promise) - await handlers.get('projectHostSetups:setupExistingFolder')!(null, { + const setupResult = handlers.get('projectHostSetups:setupExistingFolder')!(null, { projectId: project.id, hostId: 'local', path: existing.path, kind: 'git', setupMethod: 'imported-existing-folder' }) + await vi.waitFor(() => + expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, aligned) + ) + expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled() + preparation.resolve() + await setupResult - expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, aligned) + expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ + dirtyRepoIdentities: [{ repoId: aligned.id, hostId: 'local' }] + }) expect(mockStore.addRepo).not.toHaveBeenCalled() }) @@ -331,7 +346,7 @@ describe('repos:add + repos:clone', () => { expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) - it('prepares and invalidates roots when repos:update changes worktree base path', () => { + it('prepares and invalidates roots when repos:update changes worktree base path', async () => { const updated = { id: 'repo-update-root', path: '/tmp/repo-update-root', @@ -342,7 +357,7 @@ describe('repos:add + repos:clone', () => { } mockStore.updateRepo.mockReturnValue(updated) - const result = handlers.get('repos:update')!(null, { + const result = await handlers.get('repos:update')!(null, { repoId: updated.id, updates: { worktreeBasePath: ' ../worktrees ' } }) @@ -355,7 +370,36 @@ describe('repos:add + repos:clone', () => { expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) - it('persists agent worktree visibility through local repos:update', () => { + it('waits for deferred root preparation before scheduling the watcher', async () => { + const updated = { + id: 'repo-deferred-root', + path: '/tmp/repo-deferred-root', + displayName: 'repo-deferred-root', + kind: 'git', + badgeColor: '#22c55e', + worktreeBasePath: '../worktrees' + } + const preparation = Promise.withResolvers() + mockStore.updateRepo.mockReturnValue(updated) + prepareLocalWorktreeRootForRepoMock.mockReturnValueOnce(preparation.promise) + + const result = handlers.get('repos:update')!(null, { + repoId: updated.id, + updates: { worktreeBasePath: '../worktrees' } + }) + await vi.waitFor(() => + expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, updated) + ) + expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled() + + preparation.resolve() + await expect(result).resolves.toBe(updated) + expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ + dirtyRepoIdentities: [{ repoId: updated.id, hostId: 'local' }] + }) + }) + + it('persists agent worktree visibility through local repos:update', async () => { const updated = { id: 'repo-agent-visibility', path: '/tmp/repo-agent-visibility', @@ -366,7 +410,7 @@ describe('repos:add + repos:clone', () => { } mockStore.updateRepo.mockReturnValue(updated) - const result = handlers.get('repos:update')!(null, { + const result = await handlers.get('repos:update')!(null, { repoId: updated.id, updates: { agentWorktreeVisibility: 'show' } }) @@ -377,7 +421,7 @@ describe('repos:add + repos:clone', () => { }) }) - it('validates source definitions and preferences through local repos:update', () => { + it('validates source definitions and preferences through local repos:update', async () => { const updated = { id: 'repo-source-visibility', path: '/tmp/repo-source-visibility', @@ -387,7 +431,7 @@ describe('repos:add + repos:clone', () => { } mockStore.updateRepo.mockReturnValue(updated) - handlers.get('repos:update')!(null, { + await handlers.get('repos:update')!(null, { repoId: updated.id, updates: { customWorktreeVisibilitySources: [ @@ -410,7 +454,7 @@ describe('repos:add + repos:clone', () => { }) }) - it('prepares and invalidates roots when project host setup update changes worktree base path', () => { + it('prepares and invalidates roots when project host setup update changes worktree base path', async () => { const repo = { id: 'repo-setup-update-root', path: '/tmp/repo-setup-update-root', @@ -426,12 +470,12 @@ describe('repos:add + repos:clone', () => { } mockStore.updateProjectHostSetup.mockReturnValue(result) - expect( + await expect( handlers.get('projectHostSetups:update')!(null, { setupId: 'setup-1', updates: { worktreeBasePath: '../worktrees' } }) - ).toBe(result) + ).resolves.toBe(result) expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, repo) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index d4a9fc7f0ab..34fcf51c974 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -91,6 +91,7 @@ import { getActiveMultiplexer } from './ssh' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { track } from '../telemetry/client' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' +import { getWorktreeBaseRepoIdentity } from './worktree-base-directory-watch-target-cache' import { wakeFolderRepoGitUpgradeWatch } from './folder-repo-git-upgrade-wake' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' @@ -1329,7 +1330,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Why one shared reference: enrichment dedupes coalesced callers by callback identity, so a fresh // closure per list call would stack up (and re-broadcast) for the length of a slow sweep. - const broadcastReposChanged = (): void => notifyReposChanged(mainWindow) + const broadcastReposChanged = (): void => notifyReposChanged(mainWindow, null) ipcMain.handle('repos:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged }) @@ -1375,14 +1376,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (!result) { throw new Error(`Project not found: ${args.projectId}`) } - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) return result } ) ipcMain.handle( 'projectHostSetups:update', - (_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => { + async (_event, rawArgs: ProjectHostSetupUpdateArgs): Promise => { const args = parseProjectGroupIpcArgs( ProjectHostSetupUpdateIpcArgs, rawArgs, @@ -1393,10 +1394,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v throw new Error(`Project host setup not found: ${args.setupId}`) } if ('worktreeBasePath' in args.updates && result.repo) { - void prepareLocalWorktreeRootForRepo(store, result.repo) + await prepareLocalWorktreeRootForRepo(store, result.repo) invalidateAuthorizedRootsCache() } - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, result.repo ? [result.repo] : null) return result } ) @@ -1413,7 +1414,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) } - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, result.repo ? [result.repo] : null) return result } ) @@ -1468,11 +1469,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v throw err } invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) emitRepoAdded('folder_picker', result.alreadyExisted) if (result.alreadyExisted) { await prepareLocalWorktreeRootForRepo(store, aligned.repo) } + notifyReposChanged(mainWindow, [aligned.repo]) return aligned } ) @@ -1525,7 +1526,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ...args, creatorProvenance: { kind: 'host' } }) - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) return workspace } ) @@ -1564,7 +1565,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates) if (updated) { - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) } return updated } @@ -1578,7 +1579,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) const deleted = store.removeFolderWorkspace(args.folderWorkspaceId) if (deleted) { - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) } return deleted }) @@ -1596,7 +1597,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v parentGroupId: args.parentGroupId ?? null, createdFrom: args.createdFrom ?? 'manual' }) - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) return group }) @@ -1608,7 +1609,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) const updated = store.updateProjectGroup(args.groupId, args.updates) if (updated) { - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) } return updated }) @@ -1621,7 +1622,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) const deleted = store.deleteProjectGroup(args.groupId) if (deleted) { - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) } return deleted }) @@ -1634,7 +1635,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order) if (moved) { - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) } return moved }) @@ -1698,6 +1699,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v error: 'Repository was not found in the nested repo scan result' }) ) + const watchChangedRepos: Repo[] = [] const importedProjectIdsByRepoPath = new Map() const importTargetResolver = createNestedRepoImportTargetResolver() @@ -1779,6 +1781,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v : {}) } store.addRepo(repo) + watchChangedRepos.push(repo) await prepareLocalWorktreeRootForRepo(store, repo) if (args.connectionId) { getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { @@ -1807,7 +1810,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } } invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, watchChangedRepos.length > 0 ? watchChangedRepos : null) const rootGroup = groupResolver.getRootGroup() return { ...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}), @@ -1833,7 +1836,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v await prepareLocalWorktreeRootForRepo(store, result.repo) } invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [result.repo]) emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git') return { repo: result.repo } } @@ -1854,7 +1857,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if ('error' in result) { return result } - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [result.repo]) emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git') return { repo: result.repo } } @@ -1875,7 +1878,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if ('error' in result) { return result } - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [result.repo]) return result } ) @@ -2037,7 +2040,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v store.addRepo(repo) await prepareLocalWorktreeRootForRepo(store, repo) invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [repo]) // Why: repos:create git-inits when kind is 'git', so repoKind is the true git-vs-folder signal. emitRepoAdded('folder_picker', false, repoKind === 'git') return { repo } @@ -2051,7 +2054,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : [] const applied = store.reorderRepos(ids) if (applied) { - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) return { status: 'applied' } } return { status: 'rejected' } @@ -2071,7 +2074,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : [] const applied = store.reorderReposForHost(ids, hostId) if (applied) { - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, null) return { status: 'applied' } } return { status: 'rejected' } @@ -2079,9 +2082,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { + const removedRepo = store.getRepo(args.repoId) store.removeProject(args.repoId) invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, removedRepo ? [removedRepo] : null) }) // Why: forget a project on one execution host without disturbing the same repo id on other hosts (SSH-workspace forget flow). @@ -2092,15 +2096,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (!hostId) { throw new Error(`Invalid host ID: ${args.hostId}`) } + const removedRepo = store + .getRepos() + .find((repo) => repo.id === args.repoId && getRepoExecutionHostId(repo) === hostId) store.removeProjectForHost(args.repoId, hostId) invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, removedRepo ? [removedRepo] : null) } ) ipcMain.handle( 'repos:update', - ( + async ( _event, args: { repoId: string @@ -2289,10 +2296,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v : store.updateRepo(args.repoId, updates) if (updated) { if ('worktreeBasePath' in updates) { - void prepareLocalWorktreeRootForRepo(store, updated) + await prepareLocalWorktreeRootForRepo(store, updated) invalidateAuthorizedRootsCache() } - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [updated]) } return updated } @@ -2528,7 +2535,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (updated) { await prepareLocalWorktreeRootForRepo(store, updated) invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [updated]) // Why: folder→git upgrade is a real new git repo provisioning event. emitRepoAdded('clone_url', false, true) return updated @@ -2554,7 +2561,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v store.addRepo(repo) await prepareLocalWorktreeRootForRepo(store, repo) invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [repo]) emitRepoAdded('clone_url', false, true) return repo } finally { @@ -2574,7 +2581,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v args: { connectionId: string; url: string; destination: string } ): Promise => { const repo = await cloneRemoteRepo(store, mainWindow, args) - notifyReposChanged(mainWindow) + notifyReposChanged(mainWindow, [repo]) return repo } ) @@ -2771,7 +2778,10 @@ function getRepoForExecutionHost( ) } -export function notifyReposChanged(mainWindow: BrowserWindow): void { +export function notifyReposChanged( + mainWindow: BrowserWindow, + watcherChangedRepos?: readonly Repo[] | null +): void { wakeFolderRepoGitUpgradeWatch() if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('repos:changed') @@ -2784,7 +2794,13 @@ export function notifyReposChanged(mainWindow: BrowserWindow): void { // Why: a broadcast failure must never fail the mutation the user actually asked for. console.error('[repos] failed to notify remote clients of repo change', err) } - scheduleCurrentWorktreeBaseDirectoryWatcherSync() + if (watcherChangedRepos !== null) { + scheduleCurrentWorktreeBaseDirectoryWatcherSync( + watcherChangedRepos + ? { dirtyRepoIdentities: watcherChangedRepos.map(getWorktreeBaseRepoIdentity) } + : { fullRebuild: true } + ) + } } function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void { diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index af7117b086f..25a8e6f392e 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -11,7 +11,8 @@ const { previewWarpThemeImportMock, prepareLocalWorktreeRootsForReposMock, resolveEnvironmentMock, - rebuildAppMenuMock + rebuildAppMenuMock, + scheduleWorktreeWatcherSyncMock } = vi.hoisted(() => ({ applyAppIconMock: vi.fn(), applyAgentStatusHooksEnabledMock: vi.fn(), @@ -23,7 +24,8 @@ const { previewWarpThemeImportMock: vi.fn(), prepareLocalWorktreeRootsForReposMock: vi.fn(), resolveEnvironmentMock: vi.fn(), - rebuildAppMenuMock: vi.fn() + rebuildAppMenuMock: vi.fn(), + scheduleWorktreeWatcherSyncMock: vi.fn() })) vi.mock('electron', () => ({ @@ -57,6 +59,10 @@ vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootsForRepos: prepareLocalWorktreeRootsForReposMock })) +vi.mock('./worktree-base-directory-watcher', () => ({ + scheduleCurrentWorktreeBaseDirectoryWatcherSync: scheduleWorktreeWatcherSyncMock +})) + vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: rebuildAppMenuMock })) @@ -100,6 +106,7 @@ describe('registerSettingsHandlers', () => { return { id: 'windows-2' } }) rebuildAppMenuMock.mockClear() + scheduleWorktreeWatcherSyncMock.mockClear() browserWindowGetAllWindowsMock.mockReset() store.getSettings.mockReset() store.updateSettings.mockReset() @@ -395,6 +402,7 @@ describe('registerSettingsHandlers', () => { await handler(settingsInvokeEvent, { workspaceDir: '/new/workspaces' }) expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store) + expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true }) }) it('prepares local worktree roots when workspace nesting changes', async () => { @@ -410,6 +418,29 @@ describe('registerSettingsHandlers', () => { await handler(settingsInvokeEvent, { nestWorkspaces: true }) expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store) + expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true }) + }) + + it('waits for deferred root creation before scheduling a full watcher rebuild', async () => { + const preparation = Promise.withResolvers() + store.getSettings.mockReturnValue({ workspaceDir: '/old/workspaces', nestWorkspaces: false }) + store.updateSettings.mockReturnValue({ workspaceDir: '/new/workspaces', nestWorkspaces: false }) + prepareLocalWorktreeRootsForReposMock.mockReturnValueOnce(preparation.promise) + registerSettingsHandlers(store as never) + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + const result = handler(settingsInvokeEvent, { workspaceDir: '/new/workspaces' }) + await vi.waitFor(() => + expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store) + ) + expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled() + + preparation.resolve() + await result + expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true }) }) it('does not prepare local worktree roots when workspace layout values do not change', async () => { @@ -425,6 +456,7 @@ describe('registerSettingsHandlers', () => { await handler(settingsInvokeEvent, { workspaceDir: '/workspaces', nestWorkspaces: false }) expect(prepareLocalWorktreeRootsForReposMock).not.toHaveBeenCalled() + expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled() }) it('does not accept floating workspace trust grants from renderer settings IPC', async () => { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index ddf9272240b..fc2725ae454 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -232,8 +232,8 @@ export function registerSettingsHandlers( ('workspaceDir' in sanitizedArgs && before.workspaceDir !== result.workspaceDir) || ('nestWorkspaces' in sanitizedArgs && before.nestWorkspaces !== result.nestWorkspaces) ) { - void prepareLocalWorktreeRootsForRepos(store) - scheduleCurrentWorktreeBaseDirectoryWatcherSync() + await prepareLocalWorktreeRootsForRepos(store) + scheduleCurrentWorktreeBaseDirectoryWatcherSync({ fullRebuild: true }) } if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) { rebuildAppMenu() diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 7cf91ce80de..427d198714b 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -30,6 +30,7 @@ export type WorktreeBaseWatchTarget = { kind: WorktreeBaseWatchKind path: string connectionId?: string + providerGeneration?: number repos: Map /** Exact upstream leaf selected by accepted active-worktree status. */ gitStatusRefPaths?: ReadonlySet diff --git a/src/main/ipc/worktree-base-directory-watch-target-cache.ts b/src/main/ipc/worktree-base-directory-watch-target-cache.ts new file mode 100644 index 00000000000..d99a886e472 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-target-cache.ts @@ -0,0 +1,154 @@ +import type { Store } from '../persistence' +import { getSshFilesystemProviderGeneration } from '../providers/ssh-filesystem-dispatch' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { resolveWorktreeBaseDirectoryWatchTargetsForRepo } from './worktree-base-directory-watch-targets' +import { getWorktreePathSettings } from './worktree-logic' + +export type WorktreeBaseRepoIdentity = { + repoId: string + hostId: string +} + +export type WorktreeBaseDirectoryWatchTargetBuildOptions = { + fullRebuild?: boolean + dirtyRepoIdentities?: readonly WorktreeBaseRepoIdentity[] + dirtyConnectionIds?: readonly string[] + signal?: AbortSignal +} + +type CachedRepoTargets = { + fingerprint: string + targets: Map +} +export type WorktreeBaseDirectoryWatchTargetRetry = { + identity: WorktreeBaseRepoIdentity + version: string + connectionId?: string + providerGeneration: number +} + +export type WorktreeBaseDirectoryWatchTargetBuildResult = { + targets: Map + retries: WorktreeBaseDirectoryWatchTargetRetry[] +} + +const repoTargetCache = new Map() + +export function getWorktreeBaseRepoIdentity(repo: Repo): WorktreeBaseRepoIdentity { + return { + repoId: repo.id, + hostId: getRepoExecutionHostId(repo) + } +} + +function getIdentityKey(identity: WorktreeBaseRepoIdentity): string { + return `${identity.hostId}\0${identity.repoId}` +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw signal.reason ?? new Error('Worktree base directory watch target build aborted') + } +} + +function getRepoTargetFingerprint(repo: Repo, settings: GlobalSettings): string { + const pathSettings = getWorktreePathSettings(repo, settings) + return JSON.stringify([ + repo.id, + getRepoExecutionHostId(repo), + repo.path, + repo.kind ?? 'git', + repo.connectionId ?? null, + repo.worktreeBasePath ?? null, + pathSettings.workspaceDir, + pathSettings.nestWorkspaces, + repo.connectionId ? getSshFilesystemProviderGeneration(repo.connectionId) : 0 + ]) +} + +function mergeRepoTargets( + targets: Map, + repoTargets: Map +): void { + for (const [key, target] of repoTargets) { + const existing = targets.get(key) + if (existing) { + for (const [repoId, config] of target.repos) { + existing.repos.set(repoId, config) + } + continue + } + targets.set(key, { ...target, repos: new Map(target.repos) }) + } +} + +export async function buildWorktreeBaseDirectoryWatchTargets( + store: Store, + options: WorktreeBaseDirectoryWatchTargetBuildOptions = {} +): Promise { + const settings = store.getSettings() + const signal = options.signal + const liveRepos = new Map( + store + .getRepos() + .map((repo) => [getIdentityKey(getWorktreeBaseRepoIdentity(repo)), repo] as const) + ) + const fullRebuild = options.fullRebuild === true || repoTargetCache.size === 0 + const dirtyRepoKeys = new Set(options.dirtyRepoIdentities?.map(getIdentityKey) ?? []) + const dirtyConnectionIds = new Set(options.dirtyConnectionIds ?? []) + const nextCache = new Map(repoTargetCache) + const retries: WorktreeBaseDirectoryWatchTargetRetry[] = [] + + for (const key of nextCache.keys()) { + if (!liveRepos.has(key)) { + nextCache.delete(key) + } + } + + for (const [identityKey, repo] of liveRepos) { + throwIfAborted(signal) + const fingerprint = getRepoTargetFingerprint(repo, settings) + const cached = nextCache.get(identityKey) + const isDirty = + fullRebuild || + dirtyRepoKeys.has(identityKey) || + (repo.connectionId ? dirtyConnectionIds.has(repo.connectionId) : false) || + cached?.fingerprint !== fingerprint + if (!isDirty) { + continue + } + const resolution = await resolveWorktreeBaseDirectoryWatchTargetsForRepo(repo, settings, signal) + throwIfAborted(signal) + if (resolution.transientFailure) { + retries.push({ + identity: getWorktreeBaseRepoIdentity(repo), + version: fingerprint, + ...(repo.connectionId ? { connectionId: repo.connectionId } : {}), + providerGeneration: repo.connectionId + ? getSshFilesystemProviderGeneration(repo.connectionId) + : 0 + }) + continue + } + nextCache.set(identityKey, { fingerprint, targets: resolution.targets }) + } + + throwIfAborted(signal) + repoTargetCache.clear() + for (const [key, entry] of nextCache) { + repoTargetCache.set(key, entry) + } + + const targets = new Map() + for (const entry of repoTargetCache.values()) { + mergeRepoTargets(targets, entry.targets) + } + return { targets, retries } +} + +export function clearWorktreeBaseDirectoryWatchTargetCache(): void { + repoTargetCache.clear() +} diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts index cfa825e09ab..ecc2417f5f7 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -1,7 +1,6 @@ import { normalize } from 'node:path' import { realpath, stat } from 'node:fs/promises' import type { Stats } from 'node:fs' -import type { Store } from '../persistence' import type { FileStat, IFilesystemProvider } from '../providers/types' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' @@ -15,14 +14,17 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + getSshFilesystemProvider, + getSshFilesystemProviderGeneration +} from '../providers/ssh-filesystem-dispatch' import { computeWorkspaceRoot, getWorktreePathSettings, hasRepoWorktreeBasePath } from './worktree-logic' import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' -import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory' +import { probeWorktreeCommonGitDirectory } from './worktree-common-git-directory' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchKind, @@ -31,6 +33,19 @@ import type { const missingRootWarnings = new Set() const skippedWslWarnings = new Set() +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw signal.reason ?? new Error('Worktree base directory watch target build aborted') + } +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) +} function normalizeWatchKey(pathValue: string): string { return normalizeRuntimePathForComparison(normalize(pathValue)) @@ -70,9 +85,11 @@ async function addTarget( kind: WorktreeBaseWatchKind, pathValue: string, config: WorktreeBaseRepoWatchConfig, - connectionId?: string + connectionId?: string, + signal?: AbortSignal ): Promise { const watchedPath = await canonicalizeExistingPath(pathValue, connectionId) + throwIfAborted(signal) const key = `${kind}:${connectionId ?? 'local'}:${normalizeWatchKey(watchedPath)}` const existing = targets.get(key) if (existing) { @@ -83,7 +100,12 @@ async function addTarget( key, kind, path: watchedPath, - ...(connectionId ? { connectionId } : {}), + ...(connectionId + ? { + connectionId, + providerGeneration: getSshFilesystemProviderGeneration(connectionId) + } + : {}), repos: new Map([[config.repoId, config]]) }) } @@ -125,8 +147,9 @@ async function maybeAddBaseTarget( targets: Map, repo: Repo, settings: GlobalSettings, - connectionId?: string -): Promise { + connectionId?: string, + signal?: AbortSignal +): Promise { const pathSettings = getWorktreePathSettings(repo, settings) const { workspaceRoot, nestWorkspaces } = getBaseWatchLayout(repo, pathSettings, connectionId) // Why: WSL UNC roots are unreliable for native watching; avoid project-level polling. @@ -137,7 +160,7 @@ async function maybeAddBaseTarget( `[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}` ) } - return + return false } const config = { @@ -147,23 +170,27 @@ async function maybeAddBaseTarget( } const remoteProvider = getRemoteProvider(connectionId) if (connectionId && !remoteProvider) { - return + return true } + let transientFailure = false try { const rootStat = remoteProvider ? await remoteProvider.stat(workspaceRoot) : await stat(workspaceRoot) if (isDirectoryStat(rootStat)) { - await addTarget(targets, 'base', workspaceRoot, config, connectionId) + await addTarget(targets, 'base', workspaceRoot, config, connectionId, signal) } - } catch { + } catch (error) { + throwIfAborted(signal) + transientFailure = !isMissingPathError(error) const key = normalizeWatchKey(workspaceRoot) if (shouldEmitBoundedWarning(missingRootWarnings, key)) { console.warn(`[worktree-base-watcher] worktree root unavailable: ${workspaceRoot}`) } } + throwIfAborted(signal) - const commonDir = await resolveWorktreeCommonGitDirectory( + const commonDirProbe = await probeWorktreeCommonGitDirectory( repo, remoteProvider ? { @@ -172,28 +199,36 @@ async function maybeAddBaseTarget( } : undefined ) - if (commonDir) { - await addTarget(targets, 'git-common', commonDir, config, connectionId) + throwIfAborted(signal) + if (commonDirProbe.path) { + await addTarget(targets, 'git-common', commonDirProbe.path, config, connectionId, signal) } + return transientFailure || commonDirProbe.transientFailure } -export async function buildWorktreeBaseDirectoryWatchTargets( - store: Store -): Promise> { - const settings = store.getSettings() +export type WorktreeBaseDirectoryRepoTargetResolution = { + targets: Map + transientFailure: boolean +} + +export async function resolveWorktreeBaseDirectoryWatchTargetsForRepo( + repo: Repo, + settings: GlobalSettings, + signal: AbortSignal | undefined +): Promise { const targets = new Map() - for (const repo of store.getRepos()) { - if (isFolderRepo(repo)) { - continue - } - const executionHostId = getRepoExecutionHostId(repo) - if (executionHostId === LOCAL_EXECUTION_HOST_ID) { - await maybeAddBaseTarget(targets, repo, settings) - } else if (repo.connectionId) { - await maybeAddBaseTarget(targets, repo, settings, repo.connectionId) - } + if (isFolderRepo(repo)) { + return { targets, transientFailure: false } } - return targets + const executionHostId = getRepoExecutionHostId(repo) + const transientFailure = + executionHostId === LOCAL_EXECUTION_HOST_ID + ? await maybeAddBaseTarget(targets, repo, settings, undefined, signal) + : repo.connectionId + ? await maybeAddBaseTarget(targets, repo, settings, repo.connectionId, signal) + : false + throwIfAborted(signal) + return { targets, transientFailure } } export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void { diff --git a/src/main/ipc/worktree-base-directory-watcher-incremental.test.ts b/src/main/ipc/worktree-base-directory-watcher-incremental.test.ts new file mode 100644 index 00000000000..24d5bb25091 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher-incremental.test.ts @@ -0,0 +1,764 @@ +import { realpath, stat } from 'node:fs/promises' +import { join, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' + +const { providerGenerations, providerGenerationListeners } = vi.hoisted(() => ({ + providerGenerations: new Map(), + providerGenerationListeners: new Set<(connectionId: string) => void>() +})) + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(async () => ''), + realpath: vi.fn(async (path: string) => path), + stat: vi.fn(async () => ({ isDirectory: () => true })) +})) + +vi.mock('./worktree-base-directory-poller', () => ({ + createWorktreePollerWindowVisibility: vi.fn(() => ({ + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} + })), + startWorktreeBaseDirectoryPoller: vi.fn() +})) + +vi.mock('./worktree-remote', () => ({ + notifyWorktreeGitStatusMetadataChanged: vi.fn(), + notifyWorktreeHeadIdentitiesChanged: vi.fn(), + notifyWorktreesChanged: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn(), + getSshFilesystemProviderGeneration: vi.fn( + (connectionId: string) => providerGenerations.get(connectionId) ?? 0 + ), + onSshFilesystemProviderGenerationChanged: vi.fn((listener: (connectionId: string) => void) => { + providerGenerationListeners.add(listener) + return () => providerGenerationListeners.delete(listener) + }) +})) + +vi.mock('./worktree-head-identity-reader', () => ({ + readGitCommonHeadIdentities: vi.fn(async () => []) +})) + +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import { notifyWorktreesChanged } from './worktree-remote' +import { + disposeWorktreeBaseDirectoryWatchers, + scheduleWorktreeBaseDirectoryWatcherSync, + setWorktreeBaseDirectoryWatcherSyncContext, + syncWorktreeBaseDirectoryWatchers +} from './worktree-base-directory-watcher' + +const absolutePath = (...parts: string[]): string => join(sep, ...parts) +const WORKTREE_ROOT = absolutePath('workspace', 'worktrees') +const settings = { + workspaceDir: WORKTREE_ROOT, + nestWorkspaces: true +} as GlobalSettings +const unsubscribeMocks = new Map>() +const pollerRepoGetters = new Map ReadonlyMap>() + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: absolutePath('workspace', 'projects', 'project'), + displayName: 'Project', + badgeColor: '#000000', + addedAt: 1, + ...overrides + } as Repo +} + +function makeRepoFleet(): Repo[] { + return [ + ...Array.from({ length: 50 }, (_, index) => + makeRepo({ + id: `repo-${index}`, + path: absolutePath('workspace', 'projects', `local-${index}`) + }) + ), + ...Array.from({ length: 25 }, (_, index) => + makeRepo({ id: `repo-${index}`, path: `/srv/a/repo-${index}`, connectionId: 'ssh-a' }) + ), + ...Array.from({ length: 25 }, (_, index) => + makeRepo({ + id: `repo-${index + 25}`, + path: `/srv/b/repo-${index + 25}`, + connectionId: 'ssh-b' + }) + ) + ] +} + +function makeStore(repos: Repo[]) { + return { + getSettings: () => settings, + getRepos: () => repos + } +} + +function makeWindow(options: { destroyed?: () => boolean } = {}) { + return { + isDestroyed: () => options.destroyed?.() ?? false, + webContents: { send: vi.fn() } + } +} + +function makeRemoteFilesystemProvider() { + const unwatch = vi.fn() + const callbacks = new Map void>() + return { + stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })), + realpath: vi.fn(async (path: string) => path), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + watch: vi.fn(async (path: string, callback: (events: never[]) => void) => { + callbacks.set(path, callback) + return unwatch + }), + unwatch, + callbacks + } +} + +describe('incremental worktree base directory watcher synchronization', () => { + beforeEach(() => { + vi.useFakeTimers() + settings.workspaceDir = WORKTREE_ROOT + settings.nestWorkspaces = true + unsubscribeMocks.clear() + pollerRepoGetters.clear() + providerGenerations.clear() + providerGenerationListeners.clear() + vi.mocked(stat).mockImplementation(async () => ({ isDirectory: () => true }) as never) + vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined) + vi.mocked(realpath).mockImplementation(async (path) => String(path)) + vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation(async (target, getRepos) => { + const unsubscribe = vi.fn(async () => {}) + unsubscribeMocks.set(target.path, unsubscribe) + pollerRepoGetters.set(target.path, getRepos) + return { unsubscribe } + }) + }) + + afterEach(async () => { + await disposeWorktreeBaseDirectoryWatchers() + vi.useRealTimers() + vi.clearAllMocks() + }) + + function useFleetProviders() { + const providerA = makeRemoteFilesystemProvider() + const providerB = makeRemoteFilesystemProvider() + vi.mocked(getSshFilesystemProvider).mockImplementation((connectionId) => + connectionId === 'ssh-a' ? (providerA as never) : (providerB as never) + ) + return { providerA, providerB } + } + + it('probes only one host-qualified repo in a 100-repo local and SSH fleet', async () => { + const repos = makeRepoFleet() + const store = makeStore(repos) + const mainWindow = makeWindow() + const { providerA, providerB } = useFleetProviders() + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never) + vi.mocked(stat).mockClear() + vi.mocked(realpath).mockClear() + providerA.stat.mockClear() + providerB.stat.mockClear() + + repos[3] = { ...repos[3], path: absolutePath('workspace', 'renamed', 'local-3') } + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: 'repo-3', hostId: 'local' }] + }) + + expect(stat).toHaveBeenCalledTimes(2) + expect(realpath).toHaveBeenCalledTimes(2) + expect(providerA.stat).not.toHaveBeenCalled() + expect(providerB.stat).not.toHaveBeenCalled() + }) + + it('removes only a deleted repo membership without re-probing the fleet', async () => { + const repos = makeRepoFleet() + const deletedPath = repos[3].path + const store = makeStore(repos) + const { providerA, providerB } = useFleetProviders() + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + vi.mocked(stat).mockClear() + providerA.stat.mockClear() + providerB.stat.mockClear() + + repos.splice(3, 1) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyRepoIdentities: [{ repoId: 'repo-3', hostId: 'local' }] + }) + + expect(stat).not.toHaveBeenCalled() + expect(providerA.stat).not.toHaveBeenCalled() + expect(providerB.stat).not.toHaveBeenCalled() + expect([...pollerRepoGetters.get(WORKTREE_ROOT)!().keys()]).toHaveLength(49) + expect(pollerRepoGetters.get(WORKTREE_ROOT)!().has('repo-3')).toBe(false) + expect(pollerRepoGetters.get(WORKTREE_ROOT)!().has('repo-4')).toBe(true) + expect(unsubscribeMocks.get(join(deletedPath, '.git'))).toHaveBeenCalledOnce() + expect(unsubscribeMocks.get(join(repos[3].path, '.git'))).not.toHaveBeenCalled() + }) + + it('fully rebuilds every target for root and nesting settings', async () => { + const repos = makeRepoFleet() + const store = makeStore(repos) + const { providerA, providerB } = useFleetProviders() + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + + const clearProbeCounts = () => { + vi.mocked(stat).mockClear() + providerA.stat.mockClear() + providerB.stat.mockClear() + } + const expectFullProbe = () => { + expect(stat).toHaveBeenCalledTimes(100) + expect(providerA.stat).toHaveBeenCalledTimes(50) + expect(providerB.stat).toHaveBeenCalledTimes(50) + } + clearProbeCounts() + settings.nestWorkspaces = false + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + fullRebuild: true + }) + expectFullProbe() + + clearProbeCounts() + settings.workspaceDir = absolutePath('workspace', 'new-worktrees') + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + fullRebuild: true + }) + expectFullProbe() + }) + + it('invalidates only a reconnected SSH host, then only a path-changed repo', async () => { + const repos = makeRepoFleet() + const store = makeStore(repos) + const mainWindow = makeWindow() + const { providerA, providerB } = useFleetProviders() + setWorktreeBaseDirectoryWatcherSyncContext(store as never, mainWindow as never) + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never) + vi.mocked(stat).mockClear() + providerA.stat.mockClear() + providerB.stat.mockClear() + providerA.watch.mockClear() + + providerGenerations.set('ssh-a', 1) + for (const listener of providerGenerationListeners) { + listener('ssh-a') + } + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(providerA.watch).toHaveBeenCalledTimes(26)) + expect(providerA.stat).toHaveBeenCalledTimes(50) + expect(providerB.stat).not.toHaveBeenCalled() + expect(stat).not.toHaveBeenCalled() + + providerA.stat.mockClear() + const changedIndex = repos.findIndex( + (repo) => repo.connectionId === 'ssh-b' && repo.id === 'repo-25' + ) + repos[changedIndex] = { ...repos[changedIndex], path: '/srv/b/renamed-repo-25' } + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: 'repo-25', hostId: 'ssh:ssh-b' }] + }) + expect(providerA.stat).not.toHaveBeenCalled() + expect(providerB.stat).toHaveBeenCalledTimes(2) + expect(stat).not.toHaveBeenCalled() + }) + + it('retains local watches across a transient target probe and installs after retry', async () => { + const repo = makeRepo() + const store = makeStore([repo]) + const mainWindow = makeWindow() + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never) + const oldRootUnsubscribe = unsubscribeMocks.get(WORKTREE_ROOT) + const recoveredRoot = absolutePath('workspace', 'recovered-worktrees') + let rootProbeFails = true + vi.mocked(stat).mockImplementation(async (path) => { + if (String(path) === recoveredRoot && rootProbeFails) { + throw Object.assign(new Error('temporarily unavailable'), { code: 'EACCES' }) + } + return { isDirectory: () => true } as never + }) + vi.mocked(startWorktreeBaseDirectoryPoller).mockClear() + + repo.worktreeBasePath = recoveredRoot + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }] + }) + expect(oldRootUnsubscribe).not.toHaveBeenCalled() + expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled() + + rootProbeFails = false + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledWith( + expect.objectContaining({ path: recoveredRoot }), + expect.any(Function), + expect.any(Function), + expect.any(Object) + ) + ) + expect(oldRootUnsubscribe).toHaveBeenCalledOnce() + }) + + it('retains SSH watches across transient probes and retries the affected repo', async () => { + const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' }) + const store = makeStore([repo]) + const provider = makeRemoteFilesystemProvider() + vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + provider.stat.mockReset().mockRejectedValue(new Error('relay unavailable')) + provider.unwatch.mockClear() + + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'ssh:ssh-a' }] + }) + expect(provider.unwatch).not.toHaveBeenCalled() + provider.stat.mockResolvedValue({ type: 'directory', size: 0, mtime: 0 }) + + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(provider.stat).toHaveBeenCalledTimes(4)) + expect(provider.unwatch).not.toHaveBeenCalled() + }) + + it('keeps an old local watch until a path replacement subscribes', async () => { + const repo = makeRepo() + const store = makeStore([repo]) + const mainWindow = makeWindow() + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never) + const oldRootUnsubscribe = unsubscribeMocks.get(WORKTREE_ROOT) + const replacementRoot = absolutePath('workspace', 'replacement-worktrees') + vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValueOnce( + new Error('native watcher capacity') + ) + + repo.worktreeBasePath = replacementRoot + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }] + }) + expect(oldRootUnsubscribe).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(oldRootUnsubscribe).toHaveBeenCalledOnce()) + }) + + it('keeps old SSH subscriptions until a replacement retry succeeds', async () => { + const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' }) + const store = makeStore([repo]) + const oldProvider = makeRemoteFilesystemProvider() + const newProvider = makeRemoteFilesystemProvider() + vi.mocked(getSshFilesystemProvider).mockReturnValue(oldProvider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + oldProvider.unwatch.mockClear() + newProvider.watch.mockRejectedValue(new Error('watch transport unavailable')) + vi.mocked(getSshFilesystemProvider).mockReturnValue(newProvider as never) + providerGenerations.set('ssh-a', 1) + + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyConnectionIds: ['ssh-a'] + }) + expect(oldProvider.unwatch).not.toHaveBeenCalled() + expect(newProvider.watch).toHaveBeenCalledTimes(2) + + newProvider.watch.mockResolvedValue(newProvider.unwatch) + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(oldProvider.unwatch).toHaveBeenCalledTimes(2)) + }) + + it('refreshes retained reconnect routing for shared-root membership and window changes', async () => { + const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' }) + const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' }) + const repos = [repoOne, repoTwo] + const store = makeStore(repos) + let firstWindowDestroyed = false + const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed }) + const nextWindow = makeWindow() + const oldProvider = makeRemoteFilesystemProvider() + const replacementProvider = makeRemoteFilesystemProvider() + vi.mocked(getSshFilesystemProvider).mockReturnValue(oldProvider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never) + + repos.splice(0, 1) + repos.push(makeRepo({ id: 'repo-three', connectionId: 'ssh-a', path: '/srv/a/three' })) + replacementProvider.watch.mockRejectedValue(new Error('replacement unavailable')) + vi.mocked(getSshFilesystemProvider).mockReturnValue(replacementProvider as never) + providerGenerations.set('ssh-a', 1) + firstWindowDestroyed = true + await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, { + dirtyConnectionIds: ['ssh-a'] + }) + + oldProvider.callbacks.get('/srv/a')?.([ + { kind: 'create', absolutePath: '/srv/a/external-worktree/.git' } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two') + expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-three') + expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one') + }) + + it('refreshes changed-key retention after failed replacements exhaust', async () => { + const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' }) + const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' }) + const repos = [repoOne, repoTwo] + const store = makeStore(repos) + let firstWindowDestroyed = false + const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed }) + const nextWindow = makeWindow() + const provider = makeRemoteFilesystemProvider() + vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never) + + repos.splice( + 0, + repos.length, + { ...repoTwo, path: '/srv/b/two' }, + makeRepo({ id: 'repo-three', connectionId: 'ssh-a', path: '/srv/b/three' }) + ) + provider.watch.mockImplementation(async (path, callback) => { + if (path.startsWith('/srv/b')) { + throw new Error('replacement unavailable') + } + provider.callbacks.set(path, callback) + return provider.unwatch + }) + firstWindowDestroyed = true + await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, { + dirtyConnectionIds: ['ssh-a'] + }) + await vi.advanceTimersByTimeAsync(250) + await vi.advanceTimersByTimeAsync(1_000) + await vi.advanceTimersByTimeAsync(4_000) + const exhaustedAttempts = provider.watch.mock.calls.length + await vi.advanceTimersByTimeAsync(10_000) + expect(provider.watch).toHaveBeenCalledTimes(exhaustedAttempts) + + provider.callbacks.get('/srv/a')?.([ + { kind: 'create', absolutePath: '/srv/a/external-worktree/.git' } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two') + expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-three') + expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one') + }) + + it('unions distinct failed replacement memberships for one retained shared watch', async () => { + const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' }) + const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' }) + const repos = [repoOne, repoTwo] + const store = makeStore(repos) + let firstWindowDestroyed = false + const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed }) + const nextWindow = makeWindow() + const provider = makeRemoteFilesystemProvider() + vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never) + + repos[0] = { ...repoOne, path: '/srv/b/one' } + repos[1] = { ...repoTwo, path: '/srv/c/two' } + provider.watch.mockImplementation(async (path, callback) => { + if (path.startsWith('/srv/b') || path.startsWith('/srv/c')) { + throw new Error('replacement unavailable') + } + provider.callbacks.set(path, callback) + return provider.unwatch + }) + firstWindowDestroyed = true + await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, { + dirtyConnectionIds: ['ssh-a'] + }) + + provider.callbacks.get('/srv/a')?.([ + { kind: 'create', absolutePath: '/srv/a/external-worktree/.git' } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-one') + expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two') + }) + + it('keeps inverse-merge failed replacements scoped to each old watch', async () => { + const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/old-a/one' }) + const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/old-b/two' }) + const repos = [repoOne, repoTwo] + const store = makeStore(repos) + const provider = makeRemoteFilesystemProvider() + vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + + repos[0] = { ...repoOne, path: '/new/one' } + repos[1] = { ...repoTwo, path: '/new/two' } + provider.watch.mockImplementation(async (path, callback) => { + if (path.startsWith('/new')) { + throw new Error('replacement unavailable') + } + provider.callbacks.set(path, callback) + return provider.unwatch + }) + const nextWindow = makeWindow() + await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, { + dirtyConnectionIds: ['ssh-a'] + }) + + provider.callbacks.get('/old-a')?.([ + { kind: 'create', absolutePath: '/old-a/external-worktree/.git' } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-one') + expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-two') + + vi.mocked(notifyWorktreesChanged).mockClear() + provider.callbacks.get('/old-b')?.([ + { kind: 'create', absolutePath: '/old-b/external-worktree/.git' } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two') + expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one') + }) + + it('refreshes a same-version retry callback when the main window changes', async () => { + const repo = makeRepo() + const store = makeStore([repo]) + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + vi.mocked(stat).mockImplementation(async (path) => { + if (String(path).endsWith('.git')) { + throw missing + } + return { isDirectory: () => true } as never + }) + vi.mocked(startWorktreeBaseDirectoryPoller) + .mockRejectedValueOnce(new Error('watch unavailable in first window')) + .mockRejectedValueOnce(new Error('watch unavailable in next window')) + let firstWindowDestroyed = false + const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed }) + const nextWindow = makeWindow() + + await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never) + await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, { + dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }] + }) + firstWindowDestroyed = true + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(3)) + }) + + it('bounds repeated subscription failures', async () => { + const repo = makeRepo() + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + vi.mocked(stat).mockImplementation(async (path) => { + if (String(path).endsWith('.git')) { + throw missing + } + return { isDirectory: () => true } as never + }) + vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValue(new Error('watch denied')) + await syncWorktreeBaseDirectoryWatchers(makeStore([repo]) as never, makeWindow() as never) + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(3)) + await vi.advanceTimersByTimeAsync(4_000) + await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(4)) + await vi.advanceTimersByTimeAsync(10_000) + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(4) + }) + + it('cancels subscription retries after target deletion and disposal', async () => { + const repos = [makeRepo()] + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + vi.mocked(stat).mockImplementation(async (path) => { + if (String(path).endsWith('.git')) { + throw missing + } + return { isDirectory: () => true } as never + }) + vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValue(new Error('watch denied')) + const store = makeStore(repos) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + repos.splice(0, 1) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }] + }) + await vi.advanceTimersByTimeAsync(10_000) + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledOnce() + + repos.push(makeRepo({ id: 'repo-dispose' })) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyRepoIdentities: [{ repoId: 'repo-dispose', hostId: 'local' }] + }) + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2) + await disposeWorktreeBaseDirectoryWatchers() + await vi.advanceTimersByTimeAsync(10_000) + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2) + }) + + it('cancels stale provider-generation retries', async () => { + const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' }) + const store = makeStore([repo]) + const failingProvider = makeRemoteFilesystemProvider() + failingProvider.watch.mockRejectedValue(new Error('watch unavailable')) + vi.mocked(getSshFilesystemProvider).mockReturnValue(failingProvider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + + const currentProvider = makeRemoteFilesystemProvider() + providerGenerations.set('ssh-a', 1) + vi.mocked(getSshFilesystemProvider).mockReturnValue(currentProvider as never) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyConnectionIds: ['ssh-a'] + }) + expect(currentProvider.watch).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(10_000) + expect(currentProvider.watch).toHaveBeenCalledTimes(2) + }) + + it('merges an aborted first invalidation into one non-overlapping trailing pass', async () => { + const repos = makeRepoFleet() + const store = makeStore(repos) + useFleetProviders() + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + const firstProbeGate = Promise.withResolvers() + let activeProbes = 0 + let maxActiveProbes = 0 + let deferFirstProbe = true + vi.mocked(stat).mockClear() + vi.mocked(stat).mockImplementation(async () => { + activeProbes++ + maxActiveProbes = Math.max(maxActiveProbes, activeProbes) + if (deferFirstProbe) { + deferFirstProbe = false + await firstProbeGate.promise + } + activeProbes-- + return { isDirectory: () => true } as never + }) + + repos[0] = { ...repos[0], path: absolutePath('workspace', 'projects', 'repo-0-v1') } + const first = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyRepoIdentities: [{ repoId: 'repo-0', hostId: 'local' }] + }) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) + repos[1] = { ...repos[1], path: absolutePath('workspace', 'projects', 'repo-1-v2') } + const second = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }] + }) + repos[2] = { ...repos[2], path: absolutePath('workspace', 'projects', 'repo-2-v3') } + const third = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, { + dirtyRepoIdentities: [{ repoId: 'repo-2', hostId: 'local' }] + }) + firstProbeGate.resolve() + await Promise.all([first, second, third]) + + expect(stat).toHaveBeenCalledTimes(7) + expect(maxActiveProbes).toBe(1) + }) + + it('carries the active invalidation into a scheduled trailing pass', async () => { + const repos = makeRepoFleet() + const store = makeStore(repos) + const mainWindow = makeWindow() + useFleetProviders() + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never) + const firstProbeGate = Promise.withResolvers() + let deferFirstProbe = true + vi.mocked(stat).mockClear() + vi.mocked(stat).mockImplementation(async () => { + if (deferFirstProbe) { + deferFirstProbe = false + await firstProbeGate.promise + } + return { isDirectory: () => true } as never + }) + + repos[0] = { ...repos[0], path: absolutePath('workspace', 'projects', 'repo-0-active') } + const active = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: 'repo-0', hostId: 'local' }] + }) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) + repos[1] = { ...repos[1], path: absolutePath('workspace', 'projects', 'repo-1-scheduled') } + scheduleWorktreeBaseDirectoryWatcherSync(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }] + }) + firstProbeGate.resolve() + await active + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(stat).toHaveBeenCalledTimes(5)) + }) + + it('cancels retry timers before waiting for a blocked synchronization to drain', async () => { + const repo = makeRepo() + const store = makeStore([repo]) + const mainWindow = makeWindow() + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + vi.mocked(stat).mockImplementation(async (path) => { + if (String(path).endsWith('.git')) { + throw missing + } + return { isDirectory: () => true } as never + }) + const blockedInstall = Promise.withResolvers<{ unsubscribe: () => Promise }>() + vi.mocked(startWorktreeBaseDirectoryPoller) + .mockRejectedValueOnce(new Error('initial watch unavailable')) + .mockImplementationOnce(async () => blockedInstall.promise) + + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never) + const blockedSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }] + }) + await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)) + + const disposal = disposeWorktreeBaseDirectoryWatchers() + await vi.advanceTimersByTimeAsync(250) + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2) + blockedInstall.resolve({ unsubscribe: vi.fn(async () => {}) }) + await Promise.all([blockedSync, disposal]) + await vi.advanceTimersByTimeAsync(10_000) + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2) + }) + + it('cannot reinstall a stale path after its subscription completes', async () => { + const repo = makeRepo() + const store = makeStore([repo]) + const mainWindow = makeWindow() + await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never) + vi.mocked(startWorktreeBaseDirectoryPoller).mockClear() + const staleRoot = absolutePath('workspace', 'stale-worktrees') + const currentRoot = absolutePath('workspace', 'current-worktrees') + const staleUnsubscribe = vi.fn(async () => {}) + const staleInstall = Promise.withResolvers<{ unsubscribe: () => Promise }>() + vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementationOnce( + async () => staleInstall.promise + ) + + repo.worktreeBasePath = staleRoot + const staleSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }] + }) + await vi.waitFor(() => + expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledWith( + expect.objectContaining({ path: staleRoot }), + expect.any(Function), + expect.any(Function), + expect.any(Object) + ) + ) + repo.worktreeBasePath = currentRoot + const currentSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, { + dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }] + }) + staleInstall.resolve({ unsubscribe: staleUnsubscribe }) + await Promise.all([staleSync, currentSync]) + + expect(staleUnsubscribe).toHaveBeenCalledOnce() + expect(pollerRepoGetters.has(staleRoot)).toBe(false) + expect(pollerRepoGetters.has(currentRoot)).toBe(true) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-watcher-reconciler.ts b/src/main/ipc/worktree-base-directory-watcher-reconciler.ts new file mode 100644 index 00000000000..cdec3bd04ac --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher-reconciler.ts @@ -0,0 +1,72 @@ +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import { buildWorktreeBaseDirectoryWatchTargets } from './worktree-base-directory-watch-target-cache' +import type { WorktreeBaseDirectoryWatcherRetries } from './worktree-base-directory-watcher-retries' +import type { PendingWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync-queue' + +export type WorktreeBaseDirectoryWatchReplacementResult = 'ready' | 'failed' | 'stale' + +export async function reconcileWorktreeBaseDirectoryWatchers( + request: PendingWorktreeBaseDirectoryWatcherSync, + signal: AbortSignal, + activeWatches: ReadonlyMap, + retries: WorktreeBaseDirectoryWatcherRetries, + replaceWatch: ( + target: WorktreeBaseWatchTarget, + signal: AbortSignal + ) => Promise, + removeWatch: (key: string) => Promise, + refreshRetainedWatch: (key: string, repos: Map) => void +): Promise { + const buildResult = await buildWorktreeBaseDirectoryWatchTargets(request.store, { + fullRebuild: request.fullRebuild, + dirtyRepoIdentities: [...request.dirtyRepoIdentities.values()], + dirtyConnectionIds: [...request.dirtyConnectionIds], + signal + }) + if (signal.aborted) { + return + } + const { targets } = buildResult + retries.reconcileIntent(targets, buildResult.retries, request.store, request.mainWindow) + + const failedTargets: WorktreeBaseWatchTarget[] = [] + for (const target of targets.values()) { + if (signal.aborted) { + return + } + const result = await replaceWatch(target, signal) + retries.recordSubscriptionResult(target, result, request.store, request.mainWindow) + if (result === 'failed') { + failedTargets.push(target) + } + } + for (const [key, watch] of activeWatches) { + if (signal.aborted) { + return + } + const failedReplacementRepos = new Map() + for (const target of failedTargets) { + if ( + target.kind === watch.kind && + target.connectionId === watch.connectionId && + [...target.repos.keys()].some((repoId) => watch.repos.has(repoId)) + ) { + for (const [repoId, config] of target.repos) { + if (watch.repos.has(repoId)) { + failedReplacementRepos.set(repoId, config) + } + } + } + } + if (!targets.has(key)) { + if (failedReplacementRepos.size > 0) { + refreshRetainedWatch(key, failedReplacementRepos) + } else { + await removeWatch(key) + } + } + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher-retries.ts b/src/main/ipc/worktree-base-directory-watcher-retries.ts new file mode 100644 index 00000000000..7df23e96243 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher-retries.ts @@ -0,0 +1,109 @@ +import type { BrowserWindow } from 'electron' +import { getSshFilesystemProviderGeneration } from '../providers/ssh-filesystem-dispatch' +import type { Store } from '../persistence' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { + getWorktreeBaseRepoIdentity, + type WorktreeBaseDirectoryWatchTargetRetry, + type WorktreeBaseRepoIdentity +} from './worktree-base-directory-watch-target-cache' +import { WorktreeBaseDirectoryWatcherRetryScheduler } from './worktree-base-directory-watcher-retry-scheduler' + +export class WorktreeBaseDirectoryWatcherRetries { + private readonly scheduler = new WorktreeBaseDirectoryWatcherRetryScheduler() + private readonly desiredTargetVersions = new Map() + + constructor( + private readonly requestSync: ( + store: Store, + mainWindow: BrowserWindow, + identities: WorktreeBaseRepoIdentity[] + ) => void + ) {} + + private targetVersion(target: WorktreeBaseWatchTarget): string { + return `${target.providerGeneration ?? 0}\0${target.path}` + } + + reconcileIntent( + targets: Map, + retries: WorktreeBaseDirectoryWatchTargetRetry[], + store: Store, + mainWindow: BrowserWindow + ): void { + this.desiredTargetVersions.clear() + const validRetryKeys = new Set() + for (const target of targets.values()) { + this.desiredTargetVersions.set(target.key, this.targetVersion(target)) + validRetryKeys.add(`subscribe:${target.key}`) + } + for (const retry of retries) { + validRetryKeys.add(`resolve:${retry.identity.hostId}\0${retry.identity.repoId}`) + } + this.scheduler.retainKeys(validRetryKeys) + + for (const retry of retries) { + const retryKey = `resolve:${retry.identity.hostId}\0${retry.identity.repoId}` + this.scheduler.schedule(retryKey, retry.version, () => { + const repoStillExists = store.getRepos().some((repo) => { + const repoHostId = getRepoExecutionHostId(repo) + return repo.id === retry.identity.repoId && repoHostId === retry.identity.hostId + }) + const providerIsCurrent = + !retry.connectionId || + getSshFilesystemProviderGeneration(retry.connectionId) === retry.providerGeneration + if (!repoStillExists || !providerIsCurrent || mainWindow.isDestroyed()) { + this.scheduler.clear(retryKey) + return + } + this.requestSync(store, mainWindow, [retry.identity]) + }) + } + } + + recordSubscriptionResult( + target: WorktreeBaseWatchTarget, + result: 'ready' | 'failed' | 'stale', + store: Store, + mainWindow: BrowserWindow + ): void { + const retryKey = `subscribe:${target.key}` + if (result === 'ready') { + this.scheduler.clear(retryKey) + return + } + if (result !== 'failed') { + return + } + const identities = store + .getRepos() + .filter((repo) => { + const identity = getWorktreeBaseRepoIdentity(repo) + return ( + target.repos.has(repo.id) && + (target.connectionId + ? repo.connectionId === target.connectionId + : identity.hostId === 'local') + ) + }) + .map(getWorktreeBaseRepoIdentity) + const version = this.targetVersion(target) + this.scheduler.schedule(retryKey, version, () => { + if (this.desiredTargetVersions.get(target.key) !== version || mainWindow.isDestroyed()) { + this.scheduler.clear(retryKey) + return + } + this.requestSync(store, mainWindow, identities) + }) + } + + activate(): void { + this.scheduler.activate() + } + + dispose(): void { + this.scheduler.dispose() + this.desiredTargetVersions.clear() + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher-retry-scheduler.ts b/src/main/ipc/worktree-base-directory-watcher-retry-scheduler.ts new file mode 100644 index 00000000000..e735257f43b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher-retry-scheduler.ts @@ -0,0 +1,74 @@ +type RetryEntry = { + version: string + attempts: number + timer: NodeJS.Timeout | null + action: () => void +} + +const RETRY_DELAYS_MS = [250, 1_000, 4_000] as const + +export class WorktreeBaseDirectoryWatcherRetryScheduler { + private readonly entries = new Map() + private disposed = false + + schedule(key: string, version: string, action: () => void): boolean { + if (this.disposed) { + return false + } + const previous = this.entries.get(key) + if (previous?.version === version && previous.timer) { + previous.action = action + return true + } + if (previous?.version !== version) { + clearTimeout(previous?.timer ?? undefined) + this.entries.delete(key) + } + const attempts = previous?.version === version ? previous.attempts : 0 + if (attempts >= RETRY_DELAYS_MS.length) { + return false + } + + const entry: RetryEntry = { + version, + attempts: attempts + 1, + timer: null, + action + } + entry.timer = setTimeout(() => { + if (this.disposed || this.entries.get(key) !== entry) { + return + } + entry.timer = null + entry.action() + }, RETRY_DELAYS_MS[attempts]) + this.entries.set(key, entry) + return true + } + + clear(key: string): void { + const entry = this.entries.get(key) + clearTimeout(entry?.timer ?? undefined) + this.entries.delete(key) + } + + retainKeys(keys: ReadonlySet): void { + for (const key of this.entries.keys()) { + if (!keys.has(key)) { + this.clear(key) + } + } + } + + activate(): void { + this.disposed = false + } + + dispose(): void { + this.disposed = true + for (const entry of this.entries.values()) { + clearTimeout(entry.timer ?? undefined) + } + this.entries.clear() + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher-sync-queue.ts b/src/main/ipc/worktree-base-directory-watcher-sync-queue.ts new file mode 100644 index 00000000000..c7a081e9ab7 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher-sync-queue.ts @@ -0,0 +1,148 @@ +import type { BrowserWindow } from 'electron' +import type { Store } from '../persistence' +import type { WorktreeBaseRepoIdentity } from './worktree-base-directory-watch-target-cache' + +export type WorktreeBaseDirectoryWatcherSyncRequest = { + fullRebuild?: boolean + dirtyRepoIdentities?: readonly WorktreeBaseRepoIdentity[] + dirtyConnectionIds?: readonly string[] +} + +export type PendingWorktreeBaseDirectoryWatcherSync = { + store: Store + mainWindow: BrowserWindow + fullRebuild: boolean + dirtyRepoIdentities: Map + dirtyConnectionIds: Set +} + +type RunSyncPass = ( + request: PendingWorktreeBaseDirectoryWatcherSync, + signal: AbortSignal +) => Promise + +export class WorktreeBaseDirectoryWatcherSyncQueue { + private scheduledTimer: NodeJS.Timeout | null = null + private scheduledRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null + private pendingRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null + private flight: Promise | null = null + private activeAbort: AbortController | null = null + private activeRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null + + constructor(private readonly runPass: RunSyncPass) {} + + private mergeRequest( + current: PendingWorktreeBaseDirectoryWatcherSync | null, + store: Store, + mainWindow: BrowserWindow, + request: WorktreeBaseDirectoryWatcherSyncRequest | undefined + ): PendingWorktreeBaseDirectoryWatcherSync { + const merged = current ?? { + store, + mainWindow, + fullRebuild: false, + dirtyRepoIdentities: new Map(), + dirtyConnectionIds: new Set() + } + merged.store = store + merged.mainWindow = mainWindow + merged.fullRebuild ||= request === undefined || request.fullRebuild === true + for (const identity of request?.dirtyRepoIdentities ?? []) { + merged.dirtyRepoIdentities.set(`${identity.hostId}\0${identity.repoId}`, identity) + } + for (const connectionId of request?.dirtyConnectionIds ?? []) { + merged.dirtyConnectionIds.add(connectionId) + } + return merged + } + private mergeActiveRequest( + current: PendingWorktreeBaseDirectoryWatcherSync | null + ): PendingWorktreeBaseDirectoryWatcherSync | null { + if (!this.activeRequest) { + return current + } + return this.mergeRequest(current, this.activeRequest.store, this.activeRequest.mainWindow, { + fullRebuild: this.activeRequest.fullRebuild, + dirtyRepoIdentities: [...this.activeRequest.dirtyRepoIdentities.values()], + dirtyConnectionIds: [...this.activeRequest.dirtyConnectionIds] + }) + } + + private ensureFlight(): Promise { + if (!this.flight) { + this.flight = (async () => { + while (this.pendingRequest) { + const request = this.pendingRequest + this.pendingRequest = null + this.activeRequest = request + const controller = new AbortController() + this.activeAbort = controller + try { + await this.runPass(request, controller.signal) + } finally { + if (this.activeRequest === request) { + this.activeRequest = null + } + if (this.activeAbort === controller) { + this.activeAbort = null + } + } + } + })().finally(() => { + this.flight = null + if (this.pendingRequest) { + void this.ensureFlight() + } + }) + } + return this.flight + } + + async sync( + store: Store, + mainWindow: BrowserWindow, + request?: WorktreeBaseDirectoryWatcherSyncRequest + ): Promise { + this.pendingRequest = this.mergeActiveRequest(this.pendingRequest) + this.pendingRequest = this.mergeRequest(this.pendingRequest, store, mainWindow, request) + this.activeAbort?.abort(new Error('Superseded by a newer worktree watcher synchronization')) + do { + await this.ensureFlight() + } while (this.pendingRequest || this.flight) + } + + schedule( + store: Store, + mainWindow: BrowserWindow, + request?: WorktreeBaseDirectoryWatcherSyncRequest + ): void { + this.scheduledRequest = this.mergeActiveRequest(this.scheduledRequest) + this.scheduledRequest = this.mergeRequest(this.scheduledRequest, store, mainWindow, request) + this.activeAbort?.abort(new Error('Superseded by a scheduled worktree watcher synchronization')) + clearTimeout(this.scheduledTimer ?? undefined) + this.scheduledTimer = setTimeout(() => { + this.scheduledTimer = null + const nextRequest = this.scheduledRequest + this.scheduledRequest = null + if (!nextRequest || nextRequest.mainWindow.isDestroyed()) { + return + } + void this.sync(nextRequest.store, nextRequest.mainWindow, { + fullRebuild: nextRequest.fullRebuild, + dirtyRepoIdentities: [...nextRequest.dirtyRepoIdentities.values()], + dirtyConnectionIds: [...nextRequest.dirtyConnectionIds] + }) + }, 100) + } + + async dispose(): Promise { + this.pendingRequest = null + this.scheduledRequest = null + this.activeAbort?.abort(new Error('Worktree base directory watchers disposed')) + if (this.scheduledTimer) { + clearTimeout(this.scheduledTimer) + this.scheduledTimer = null + } + await this.flight + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher-sync.ts b/src/main/ipc/worktree-base-directory-watcher-sync.ts new file mode 100644 index 00000000000..377645aa8c5 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher-sync.ts @@ -0,0 +1,67 @@ +import type { BrowserWindow } from 'electron' +import type { Store } from '../persistence' +import { onSshFilesystemProviderGenerationChanged } from '../providers/ssh-filesystem-dispatch' +import { + WorktreeBaseDirectoryWatcherSyncQueue, + type PendingWorktreeBaseDirectoryWatcherSync, + type WorktreeBaseDirectoryWatcherSyncRequest +} from './worktree-base-directory-watcher-sync-queue' + +export type WorktreeBaseDirectoryWatcherSync = { + sync: ( + store: Store, + mainWindow: BrowserWindow, + request?: WorktreeBaseDirectoryWatcherSyncRequest + ) => Promise + setContext: (store: Store, mainWindow: BrowserWindow) => void + schedule: ( + store: Store, + mainWindow: BrowserWindow, + request?: WorktreeBaseDirectoryWatcherSyncRequest + ) => void + scheduleCurrent: (request?: WorktreeBaseDirectoryWatcherSyncRequest) => void + dispose: () => Promise +} + +export function createWorktreeBaseDirectoryWatcherSync( + runPass: (request: PendingWorktreeBaseDirectoryWatcherSync, signal: AbortSignal) => Promise +): WorktreeBaseDirectoryWatcherSync { + const queue = new WorktreeBaseDirectoryWatcherSyncQueue(runPass) + let latestContext: { mainWindow: BrowserWindow; store: Store } | null = null + let unsubscribeFromProviderGenerationChanges: (() => void) | null = null + + const scheduleCurrent = (request?: WorktreeBaseDirectoryWatcherSyncRequest): void => { + if (!latestContext || latestContext.mainWindow.isDestroyed()) { + return + } + queue.schedule(latestContext.store, latestContext.mainWindow, request) + } + + return { + sync: (store, mainWindow, request) => queue.sync(store, mainWindow, request), + setContext: (store, mainWindow) => { + latestContext = { store, mainWindow } + unsubscribeFromProviderGenerationChanges ??= onSshFilesystemProviderGenerationChanged( + (connectionId) => scheduleCurrent({ dirtyConnectionIds: [connectionId] }) + ) + // Why: lean BrowserWindow test doubles omit once; real windows clear stale chrome authority. + if (typeof mainWindow.once === 'function') { + mainWindow.once('closed', () => { + if (latestContext?.mainWindow === mainWindow) { + latestContext = null + } + }) + } + }, + schedule: (store, mainWindow, request) => queue.schedule(store, mainWindow, request), + scheduleCurrent, + dispose: async () => { + latestContext = null + unsubscribeFromProviderGenerationChanges?.() + unsubscribeFromProviderGenerationChanges = null + await queue.dispose() + } + } +} + +export type { WorktreeBaseDirectoryWatcherSyncRequest } diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 9ce0712c9a0..81980733ebf 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -28,7 +28,9 @@ vi.mock('./worktree-remote', () => ({ })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() + getSshFilesystemProvider: vi.fn(), + getSshFilesystemProviderGeneration: vi.fn(() => 0), + onSshFilesystemProviderGenerationChanged: vi.fn(() => () => {}) })) vi.mock('./worktree-head-identity-reader', () => ({ diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 411faae40d4..fd897677c71 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -1,5 +1,4 @@ import type { BrowserWindow } from 'electron' -import type { Store } from '../persistence' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { createWorktreeHeadIdentityRefreshState, @@ -17,10 +16,15 @@ import { supportsWorktreeHeadIdentityRefresh } from './worktree-base-directory-notifications' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { clearWorktreeBaseDirectoryWatchTargetCache } from './worktree-base-directory-watch-target-cache' +import { clearWorktreeBaseDirectoryWatchTargetWarnings } from './worktree-base-directory-watch-targets' import { - buildWorktreeBaseDirectoryWatchTargets, - clearWorktreeBaseDirectoryWatchTargetWarnings -} from './worktree-base-directory-watch-targets' + reconcileWorktreeBaseDirectoryWatchers, + type WorktreeBaseDirectoryWatchReplacementResult +} from './worktree-base-directory-watcher-reconciler' +import { WorktreeBaseDirectoryWatcherRetries } from './worktree-base-directory-watcher-retries' +import { createWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync' +import type { PendingWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync-queue' import { createWorktreePollerWindowVisibility, startWorktreeBaseDirectoryPoller @@ -49,9 +53,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { } const activeWatches = new Map() -let syncGeneration = 0 -let scheduledSync: ReturnType | null = null -let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null + export function setWorktreeGitStatusRefWatch( args: GitStatusRefBindingRequest, resolveUpstreamRef: (signal: AbortSignal) => Promise @@ -197,31 +199,57 @@ async function subscribeTarget( return activeWatch } +type ReplaceWatchResult = WorktreeBaseDirectoryWatchReplacementResult + +async function disposeWatch(watch: ActiveWatch): Promise { + watch.disposed = true + clearTimeout(watch.notifyTimer ?? undefined) + clearPendingWorktreeBaseNotifications(watch) + await watch.subscription.unsubscribe().catch((error) => { + console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) + }) +} + async function replaceWatch( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow, - generation: number -): Promise { + signal: AbortSignal +): Promise { const previous = activeWatches.get(target.key) if (previous) { + // Keep the surviving subscription's routing current while a replacement is pending. previous.repos = target.repos previous.mainWindow = mainWindow applyActiveGitStatusRefBinding(previous) - return + if (previous.providerGeneration === target.providerGeneration) { + return 'ready' + } } try { const activeWatch = await subscribeTarget(target, mainWindow) - if (generation !== syncGeneration) { - activeWatch.disposed = true - await activeWatch.subscription.unsubscribe().catch((error) => { - console.warn(`[worktree-base-watcher] failed to unwatch stale ${target.path}:`, error) - }) - return + if (signal.aborted) { + await disposeWatch(activeWatch) + return 'stale' } applyActiveGitStatusRefBinding(activeWatch) activeWatches.set(target.key, activeWatch) + if (previous) { + await disposeWatch(previous) + if (signal.aborted) { + if (activeWatches.get(target.key) === activeWatch) { + activeWatches.delete(target.key) + } + await disposeWatch(activeWatch) + return 'stale' + } + } + return 'ready' } catch (error) { - console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error) + if (!signal.aborted) { + console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error) + return 'failed' + } + return 'stale' } } @@ -231,92 +259,63 @@ async function removeWatch(key: string): Promise { return } activeWatches.delete(key) - watch.disposed = true - clearTimeout(watch.notifyTimer ?? undefined) - clearPendingWorktreeBaseNotifications(watch) - await watch.subscription.unsubscribe().catch((error) => { - console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) - }) + await disposeWatch(watch) } -export async function syncWorktreeBaseDirectoryWatchers( - store: Store, +function refreshRetainedWatch( + key: string, + repos: WorktreeBaseWatchTarget['repos'], mainWindow: BrowserWindow +): void { + const watch = activeWatches.get(key) + if (!watch) { + return + } + watch.repos = repos + watch.mainWindow = mainWindow + applyActiveGitStatusRefBinding(watch) +} + +async function runSyncPass( + request: PendingWorktreeBaseDirectoryWatcherSync, + signal: AbortSignal ): Promise { - const generation = ++syncGeneration - const targets = await buildWorktreeBaseDirectoryWatchTargets(store) - if (generation !== syncGeneration) { + watcherRetries.activate() + if (request.mainWindow.isDestroyed()) { return } - for (const key of activeWatches.keys()) { - if (generation !== syncGeneration) { - return - } - if (!targets.has(key)) { - await removeWatch(key) - if (generation !== syncGeneration) { - return - } - } - } - for (const target of targets.values()) { - if (generation !== syncGeneration) { - return - } - await replaceWatch(target, mainWindow, generation) - if (generation !== syncGeneration) { - return + try { + await reconcileWorktreeBaseDirectoryWatchers( + request, + signal, + activeWatches, + watcherRetries, + (target, replacementSignal) => replaceWatch(target, request.mainWindow, replacementSignal), + removeWatch, + (key, repos) => refreshRetainedWatch(key, repos, request.mainWindow) + ) + } catch (error) { + if (!signal.aborted) { + console.warn('[worktree-base-watcher] failed to synchronize watch targets:', error) } } } -export function setWorktreeBaseDirectoryWatcherSyncContext( - store: Store, - mainWindow: BrowserWindow -): void { - latestSyncContext = { store, mainWindow } - // Why: older integration tests use lean BrowserWindow stubs; real windows still - // clear this context on close so stale watcher syncs cannot target dead chrome. - if (typeof mainWindow.once === 'function') { - mainWindow.once('closed', () => { - if (latestSyncContext?.mainWindow === mainWindow) { - latestSyncContext = null - } - }) - } -} +const watcherSync = createWorktreeBaseDirectoryWatcherSync(runSyncPass) +const watcherRetries = new WorktreeBaseDirectoryWatcherRetries((store, mainWindow, identities) => { + void watcherSync.sync(store, mainWindow, { dirtyRepoIdentities: identities }) +}) -export function scheduleWorktreeBaseDirectoryWatcherSync( - store: Store, - mainWindow: BrowserWindow -): void { - if (scheduledSync) { - clearTimeout(scheduledSync) - } - scheduledSync = setTimeout(() => { - scheduledSync = null - if (mainWindow.isDestroyed()) { - return - } - void syncWorktreeBaseDirectoryWatchers(store, mainWindow) - }, 100) -} - -export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void { - if (!latestSyncContext || latestSyncContext.mainWindow.isDestroyed()) { - return - } - scheduleWorktreeBaseDirectoryWatcherSync(latestSyncContext.store, latestSyncContext.mainWindow) -} +export const syncWorktreeBaseDirectoryWatchers = watcherSync.sync +export const setWorktreeBaseDirectoryWatcherSyncContext = watcherSync.setContext +export const scheduleWorktreeBaseDirectoryWatcherSync = watcherSync.schedule +export const scheduleCurrentWorktreeBaseDirectoryWatcherSync = watcherSync.scheduleCurrent export async function disposeWorktreeBaseDirectoryWatchers(): Promise { - syncGeneration++ - latestSyncContext = null clearActiveGitStatusRefBinding() - if (scheduledSync) { - clearTimeout(scheduledSync) - scheduledSync = null - } + watcherRetries.dispose() + await watcherSync.dispose() await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key))) + clearWorktreeBaseDirectoryWatchTargetCache() clearWorktreeBaseDirectoryWatchTargetWarnings() } diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts index 2a334055c17..2710b55c3a9 100644 --- a/src/main/ipc/worktree-common-git-directory.ts +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -34,33 +34,49 @@ function runtimeDirname(pathValue: string): string { } return normalized.slice(0, index) } +export type WorktreeCommonGitDirectoryProbe = { + path: string | null + transientFailure: boolean +} -export async function resolveWorktreeCommonGitDirectory( +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) +} + +export async function probeWorktreeCommonGitDirectory( repo: Repo, access: GitDirectoryAccess = {} -): Promise { +): Promise { const dotGitPath = resolveRuntimePath(repo.path, '.git') const statPath = access.stat ?? stat const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8')) try { const dotGitStat = await statPath(dotGitPath) if (isDirectoryStat(dotGitStat)) { - return dotGitPath + return { path: dotGitPath, transientFailure: false } } if (!isFileStat(dotGitStat)) { - return null + return { path: null, transientFailure: false } } const content = await readText(dotGitPath) const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim() if (!gitDir) { - return null + return { path: null, transientFailure: false } } const resolvedGitDir = resolveRuntimePath(repo.path, gitDir) - return getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees' - ? runtimeDirname(runtimeDirname(resolvedGitDir)) - : resolvedGitDir + return { + path: + getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees' + ? runtimeDirname(runtimeDirname(resolvedGitDir)) + : resolvedGitDir, + transientFailure: false + } } catch (error) { console.warn(`[worktree-base-watcher] cannot resolve git common dir for ${repo.id}:`, error) - return null + return { path: null, transientFailure: !isMissingPathError(error) } } } diff --git a/src/main/providers/ssh-filesystem-dispatch.test.ts b/src/main/providers/ssh-filesystem-dispatch.test.ts index 9b932be8c38..afa8fe378f8 100644 --- a/src/main/providers/ssh-filesystem-dispatch.test.ts +++ b/src/main/providers/ssh-filesystem-dispatch.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { getSshFilesystemProvider, + getSshFilesystemProviderGeneration, + onSshFilesystemProviderGenerationChanged, onSshFilesystemProviderRegistered, registerSshFilesystemProvider, unregisterSshFilesystemProvider @@ -66,4 +68,28 @@ describe('onSshFilesystemProviderRegistered', () => { unregisterSshFilesystemProvider('conn-4') warnSpy.mockRestore() }) + + it('advances the provider generation across reconnect and disconnect boundaries', () => { + const before = getSshFilesystemProviderGeneration('conn-generation') + registerSshFilesystemProvider('conn-generation', provider) + const registered = getSshFilesystemProviderGeneration('conn-generation') + registerSshFilesystemProvider('conn-generation', {} as IFilesystemProvider) + const reconnected = getSshFilesystemProviderGeneration('conn-generation') + unregisterSshFilesystemProvider('conn-generation') + + expect(registered).toBe(before + 1) + expect(reconnected).toBe(registered + 1) + expect(getSshFilesystemProviderGeneration('conn-generation')).toBe(reconnected + 1) + }) + + it('notifies generation subscribers for both reconnect and disconnect', () => { + const listener = vi.fn() + const unsubscribe = onSshFilesystemProviderGenerationChanged(listener) + registerSshFilesystemProvider('conn-lifecycle', provider) + unregisterSshFilesystemProvider('conn-lifecycle') + + expect(listener).toHaveBeenNthCalledWith(1, 'conn-lifecycle') + expect(listener).toHaveBeenNthCalledWith(2, 'conn-lifecycle') + unsubscribe() + }) }) diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index 04ef209bef1..eac41d74c45 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -1,6 +1,7 @@ import type { IFilesystemProvider } from './types' const sshProviders = new Map() +const sshProviderGenerations = new Map() export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' @@ -8,6 +9,27 @@ export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = // Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old // transport (file watches) needs a signal to rebuild it — nothing else marks that boundary. const registrationListeners = new Set<(connectionId: string) => void>() +const generationChangeListeners = new Set<(connectionId: string) => void>() + +function notifyGenerationChanged(connectionId: string): void { + for (const listener of generationChangeListeners) { + try { + listener(connectionId) + } catch (error) { + // Why: provider lifecycle must not fail because a cache invalidator threw. + console.warn('[ssh-filesystem] provider generation listener failed:', error) + } + } +} + +export function onSshFilesystemProviderGenerationChanged( + listener: (connectionId: string) => void +): () => void { + generationChangeListeners.add(listener) + return () => { + generationChangeListeners.delete(listener) + } +} export function onSshFilesystemProviderRegistered( listener: (connectionId: string) => void @@ -22,7 +44,9 @@ export function registerSshFilesystemProvider( connectionId: string, provider: IFilesystemProvider ): void { + sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) sshProviders.set(connectionId, provider) + notifyGenerationChanged(connectionId) for (const listener of registrationListeners) { try { listener(connectionId) @@ -34,13 +58,20 @@ export function registerSshFilesystemProvider( } export function unregisterSshFilesystemProvider(connectionId: string): void { - sshProviders.delete(connectionId) + if (sshProviders.delete(connectionId)) { + sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) + notifyGenerationChanged(connectionId) + } } export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined { return sshProviders.get(connectionId) } +export function getSshFilesystemProviderGeneration(connectionId: string): number { + return sshProviderGenerations.get(connectionId) ?? 0 +} + export function requireSshFilesystemProvider(connectionId: string): IFilesystemProvider { const provider = getSshFilesystemProvider(connectionId) if (!provider) {