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 <chyohw97@gmail.com>

* 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 <neil@stably.ai>
This commit is contained in:
Younghwan Chae
2026-10-27 18:30:12 -07:00
committed by GitHub
co-authored by Neil
parent 6631e5f6b6
commit e8d57136a2
6 changed files with 82 additions and 6 deletions
@@ -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)
@@ -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(
@@ -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
@@ -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({
@@ -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 = { ...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)
})
})
@@ -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<T extends Record<string, unknown>>(value: T): Partial<T> {
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
}