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 <m4air@Mac.localdomain>
This commit is contained in:
Neil
2026-10-11 00:30:06 -07:00
committed by GitHub
co-authored by m4air
parent cc5c85fb7a
commit d56c0fbbb3
2 changed files with 106 additions and 0 deletions
@@ -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)
@@ -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<typeof vi.fn>
invalidateResolvedWorktrees: ReturnType<typeof vi.fn>
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)
})
})