From e8d57136a2be2aef00b10a8047e335c19c0e40f9 Mon Sep 17 00:00:00 2001 From: Younghwan Chae <48156556+younghwan91@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:30:12 +0900 Subject: [PATCH] fix(runtime): re-list a repo's worktrees when repo.update changes visibility (#32783) * fix(runtime): re-list a repo's worktrees when repo.update changes visibility `orca repo set --external-worktree-visibility show` (and any other repo.update client) only emitted repos:changed, so the host renderer kept stale `visible: false` worktree rows and the newly shown worktree vanished from the sidebar. The settings controller now also emits worktrees:changed for the repo when a visibility input changes, without invalidating cached Git scans. Fixes #32764 Co-authored-by: Younghwan Chae * fix(runtime): declare notifyWorktreesChanged on RuntimeConstructionServices The repository settings controller is built in OrcaRuntimeWithPreservedBranchCleanup, but notifyWorktreesChanged is implemented further down the chain in OrcaRuntimeWithGetStatus, so the base class needs the abstract member to typecheck. --------- Co-authored-by: Neil --- .../project-host-setup-wsl-move-fence.test.ts | 3 +- .../ipc/repos/repo-ipc-wsl-move-fence.test.ts | 3 +- .../runtime-construction-services.ts | 1 + .../construction/runtime-host-services.ts | 6 +- ...ntime-repo-update-worktree-refresh.test.ts | 59 +++++++++++++++++++ .../runtime-repository-settings-controller.ts | 16 +++++ 6 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/runtime-repo-update-worktree-refresh.test.ts diff --git a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts index 867065b85bd..394e289ab3f 100644 --- a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts @@ -183,7 +183,8 @@ describe('repo settings on another host under a WSL move', () => { forgetTerminalTopology: vi.fn(), invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), - notifyReposChanged: vi.fn() + notifyReposChanged: vi.fn(), + notifyWorktreesChanged: vi.fn() }) await expect(controller.setBaseRef('repo-1', 'origin/dev')).resolves.toBe(onHost) diff --git a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts index f5cb06e207d..634c38465e1 100644 --- a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts @@ -138,7 +138,8 @@ describe('RuntimeRepositorySettingsController under a WSL move', () => { forgetTerminalTopology: vi.fn(), invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), - notifyReposChanged + notifyReposChanged, + notifyWorktreesChanged: vi.fn() }) await expect(controller.update('repo-1', { displayName: 'renamed' })).rejects.toBeInstanceOf( diff --git a/src/main/runtime/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index 2b84cd5718d..a5e6b47ad49 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -50,6 +50,7 @@ export abstract class RuntimeConstructionServices { abstract listRepos(): Repo[] abstract invalidateResolvedWorktreeCache(): void abstract invalidateWorktreeScanCacheForRepo(repoId: string): void + abstract notifyWorktreesChanged(repoId: string, options?: { invalidateGitScans?: boolean }): void protected abstract notifyReposChanged(): void protected abstract isPtyKnownExited(ptyId: string): boolean diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index 06a767d84ee..d5d3463902f 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -333,13 +333,11 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW ) protected readonly repositorySettings = new RuntimeRepositorySettingsController({ - getStore: () => this.store, + ...this.repositoryCatalogDeps, resolveRepo: (selector) => this.resolveRepoSelector(selector), forgetTerminalTopology: (repoId) => this.terminalTopologyRevisionByRepoId.delete(repoId), stopProjectTerminals: (repo, options) => this.stopTerminalsForProject(repo, options), - invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), - invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), - notifyReposChanged: () => this.notifyReposChanged() + notifyWorktreesChanged: (repoId, options) => this.notifyWorktreesChanged(repoId, options) }) protected readonly repositorySparsePresets = new RuntimeRepositorySparsePresets({ diff --git a/src/main/runtime/runtime-repo-update-worktree-refresh.test.ts b/src/main/runtime/runtime-repo-update-worktree-refresh.test.ts new file mode 100644 index 00000000000..2c539d6c2f4 --- /dev/null +++ b/src/main/runtime/runtime-repo-update-worktree-refresh.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import './orca-runtime-test-lifecycle.spec' +import { OrcaRuntimeService, listWorktrees } from './orca-runtime-test-mocks.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + makeWorktreeInfo, + store +} from './orca-runtime-test-fixtures.spec' +import type { Repo } from '../../shared/repo-types' +import type { RuntimeClientEvent } from '../../shared/runtime-client-events' + +const externalPath = '/tmp/elsewhere/external' + +async function createScannedRuntime() { + let repo: Repo = { ...store.getRepos()[0], externalWorktreeVisibility: 'hide' } + const runtime = new OrcaRuntimeService({ + ...store, + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getRepos: () => [repo], + updateRepo: (_target: Repo, updates: Partial) => { + repo = { ...repo, ...updates } + return repo + } + }) + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockResolvedValue([ + makeWorktreeInfo(TEST_REPO_PATH), + makeWorktreeInfo(externalPath) + ]) + const events: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => events.push(event)) + const isExternalVisible = async () => + (await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`)).worktrees.find( + (worktree) => worktree.path === externalPath + )?.visible + expect(await isExternalVisible()).toBe(false) + return { runtime, events, isExternalVisible } +} + +describe('repo.update worktree refresh', () => { + it('re-lists a newly shown external worktree without rescanning Git', async () => { + const { runtime, events, isExternalVisible } = await createScannedRuntime() + + await runtime.updateRepo(`id:${TEST_REPO_ID}`, { externalWorktreeVisibility: 'show' }) + + expect(events).toContainEqual({ type: 'worktreesChanged', repoId: TEST_REPO_ID }) + expect(await isExternalVisible()).toBe(true) + expect(listWorktrees).toHaveBeenCalledTimes(1) + }) + + it('skips the worktree refresh for catalog-only edits', async () => { + const { runtime, events } = await createScannedRuntime() + + await runtime.updateRepo(`id:${TEST_REPO_ID}`, { displayName: 'renamed' }) + + expect(events.some((event) => event.type === 'worktreesChanged')).toBe(false) + }) +}) diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index ea3379cdd66..0bccd322c72 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -23,6 +23,7 @@ type RuntimeRepositorySettingsDependencies = { invalidateResolvedWorktrees: () => void invalidateWorktreeScan: (repoId: string) => void notifyReposChanged: () => void + notifyWorktreesChanged: (repoId: string, options: { invalidateGitScans: false }) => void } type RepositoryUpdates = Partial< @@ -44,6 +45,8 @@ type RepositoryUpdates = Partial< | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'agentWorktreeVisibility' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' | 'projectGroupOrder' > @@ -54,6 +57,16 @@ type RepositoryUpdates = Partial< ghAccount?: GhAccountBinding | null } +// Main computes each row's `visible` from these over cached raw Git scans, so re-list without rescanning. +const WORKTREE_VISIBILITY_INPUTS = [ + 'externalWorktreeVisibility', + 'importedExternalWorktreePaths', + 'agentWorktreeVisibility', + 'worktreeBasePath', + 'customWorktreeVisibilitySources', + 'worktreeVisibilitySourcePreferences' +] as const satisfies readonly (keyof RepositoryUpdates)[] + function omitUndefined>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) @@ -120,6 +133,9 @@ export class RuntimeRepositorySettingsController { this.deps.invalidateWorktreeScan(repo.id) } this.deps.notifyReposChanged() + if (WORKTREE_VISIBILITY_INPUTS.some((key) => key in updates)) { + this.deps.notifyWorktreesChanged(repo.id, { invalidateGitScans: false }) + } return updated }