From d56c0fbbb30c1c7705ddaf95e9e2e0e1f01b1834 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:30:06 -0700 Subject: [PATCH] fix(runtime): publish project setup changes and retire stale worktree scans (#26941) Runtime setup create/update/delete changed the store without telling open clients, and a path/kind change or deletion left cached worktree scans answering for the old registration. Publish reposChanged after each successful mutation and run the shared worktree-change invalidators on path/kind changes and deletes. Fixes #26562 Co-authored-by: m4air --- .../runtime-project-host-setup-controller.ts | 18 ++++ ...ime-project-host-setup-publication.test.ts | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/main/runtime/runtime-project-host-setup-publication.test.ts diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index 6dff6bae984..fcd7ba286fa 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -22,6 +22,7 @@ import { import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta' @@ -95,6 +96,7 @@ export class RuntimeProjectHostSetupController { if (!result) { throw new Error(`Project not found: ${args.projectId}`) } + this.deps.notifyReposChanged() return result } @@ -158,6 +160,11 @@ export class RuntimeProjectHostSetupController { void prepareLocalWorktreeRootForRepo(store, result.repo) invalidateAuthorizedRootsCache() } + if (result.repo && ('path' in args.updates || 'kind' in args.updates)) { + this.retireRepoCatalog(result.repo.id) + } + this.deps.invalidateResolvedWorktrees() + this.deps.notifyReposChanged() return result } @@ -173,9 +180,20 @@ export class RuntimeProjectHostSetupController { if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) } + if (result.repo) { + this.retireRepoCatalog(result.repo.id) + } + this.deps.invalidateResolvedWorktrees() + this.deps.notifyReposChanged() return result } + // Why the shared invalidators: desktop and runtime scan caches key on repo id, not path or kind. + private retireRepoCatalog(repoId: string): void { + runWorktreeChangeInvalidators(repoId) + invalidateAuthorizedRootsCache() + } + // Why only for an explicit `force: false`: older clients omit it and must keep their delete. private assertSetupRemovalIsUnused(store: RuntimeStore, setupId: string): void { const setup = this.listSetups().find((entry) => entry.id === setupId) diff --git a/src/main/runtime/runtime-project-host-setup-publication.test.ts b/src/main/runtime/runtime-project-host-setup-publication.test.ts new file mode 100644 index 00000000000..ad39e2a51d0 --- /dev/null +++ b/src/main/runtime/runtime-project-host-setup-publication.test.ts @@ -0,0 +1,88 @@ +// Runtime RPC setup edits used to change the store silently: open clients kept the old catalog +// and cached worktree scans kept answering for the old path or kind (#26562). +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller' +import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invalidators' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { Repo } from '../../shared/repo-types' +import type { RuntimeStore } from './runtime-store-contract' + +const repo: Repo = { + id: 'repo-1', + path: '/srv/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} +const projection = projectHostSetupProjectionFromRepos([repo]) +const project = projection.projects[0] +const setup = projection.setups[0] + +let unregister: (() => void) | undefined +afterEach(() => unregister?.()) + +function makeController(): { + controller: RuntimeProjectHostSetupController + notifyReposChanged: ReturnType + invalidateResolvedWorktrees: ReturnType + retired: string[] +} { + const store = { + createProjectHostSetup: () => ({ project, setup }), + updateProjectHostSetup: () => ({ project, setup, repo }), + deleteProjectHostSetup: () => ({ project, setup, repo }), + getProjectHostSetups: () => [setup] + } + const retired: string[] = [] + unregister = registerWorktreeChangeInvalidator((repoId) => retired.push(repoId)) + const notifyReposChanged = vi.fn() + const invalidateResolvedWorktrees = vi.fn() + const controller = new RuntimeProjectHostSetupController({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only calls the setup methods stubbed above. + getStore: () => store as unknown as RuntimeStore, + listRepos: () => [repo], + addRepo: vi.fn(), + addRemoteRepo: vi.fn(), + cloneRepo: vi.fn(), + invalidateResolvedWorktrees, + invalidateWorktreeScan: vi.fn(), + notifyReposChanged, + countLiveTerminalsForRepo: () => 0 + }) + return { controller, notifyReposChanged, invalidateResolvedWorktrees, retired } +} + +describe('RuntimeProjectHostSetupController publication', () => { + it('publishes a created setup to open clients', () => { + const { controller, notifyReposChanged } = makeController() + controller.createSetup({ projectId: project.id, hostId: 'local', path: '/srv/app' }) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + }) + + it('publishes a rename without retiring cached worktree scans', () => { + const { controller, notifyReposChanged, invalidateResolvedWorktrees, retired } = + makeController() + controller.updateSetup({ setupId: setup.id, updates: { displayName: 'renamed' } }) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + expect(invalidateResolvedWorktrees).toHaveBeenCalled() + expect(retired).toEqual([]) + }) + + it.each([{ kind: 'folder' as const }, { path: '/srv/moved' }])( + 'retires cached worktree scans when a setup changes %o', + (updates) => { + const { controller, notifyReposChanged, retired } = makeController() + controller.updateSetup({ setupId: setup.id, updates }) + expect(retired).toEqual(['repo-1']) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + } + ) + + it('retires cached worktree scans and publishes a deleted setup', () => { + const { controller, notifyReposChanged, retired } = makeController() + controller.deleteSetup({ setupId: setup.id }) + expect(retired).toEqual(['repo-1']) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + }) +})