From d1aef1eee9b049d93d3b0093b5618b3829a802c2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 23 Oct 2026 17:30:59 -0700 Subject: [PATCH] refactor(worktrees): remove unreachable legacy worktree list IPC (#32942) * refactor(worktrees): remove unreachable legacy worktree list IPC The desktop and web preloads both always provide `worktrees.listDetected`, so the renderer's fallback to `worktrees.list` never ran, and nothing calls `worktrees.listAll`. Remove both IPC handlers, their preload/web wrappers, the renderer fallback, and helpers only they used. Tests that observed behaviour through the removed handlers now go through `worktrees:listDetected`. * test(worktrees): drop removed list channels from SSH listing matrix Address review: the canonical SSH listing test built channel names dynamically and still invoked worktrees:list/listAll. Also fold the detected-result reader into the shared worktree test fixtures, refresh stale comments, and assert the backfill no-op test listed authoritatively. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../ipc/filesystem-git-status-staging.test.ts | 2 +- ...thoritative-local-metadata-pruning.test.ts | 100 ++++----- .../ipc/worktrees-background-removal.test.ts | 7 +- .../worktrees-canonical-ssh-listing.test.ts | 11 +- ...ktrees-create-metadata-persistence.test.ts | 90 ++++++-- ...ktrees-discovery-metadata-backfill.test.ts | 194 +++++------------ src/main/ipc/worktrees-failed-removal.test.ts | 14 +- .../ipc/worktrees-lineage-hydration.test.ts | 54 +++-- .../worktrees-listing-fallback-rows.test.ts | 139 ++---------- src/main/ipc/worktrees-test-fixtures.ts | 20 +- src/main/ipc/worktrees-windows.test.ts | 10 +- src/main/ipc/worktrees.ts | 2 - .../listing/folder-workspace-catalog.ts | 15 -- .../register-worktree-catalog-handlers.ts | 206 ------------------ .../listing/ssh-worktree-fallback.ts | 10 - .../runtime/repo-worktree-row-resolution.ts | 2 +- ...me-managed-worktree-metadata-sweep.test.ts | 4 +- src/preload/api/worktree-api.ts | 2 - src/preload/api/worktrees-bridge.ts | 3 - .../lib/desktop-created-server-tabs.test.ts | 1 - .../agent-status-worktree-purge-leak.test.ts | 1 - ...wner-warning-worktree-removal-leak.test.ts | 1 - ...-worktree-purge-terminal-maps-leak.test.ts | 2 +- .../src/store/slices/diffComments.test.ts | 1 - .../editor-state-worktree-purge-leak.test.ts | 1 - ...tion-records-worktree-removal-leak.test.ts | 1 - .../native-chat-launch-draft-teardown.test.ts | 1 - .../store/slices/recently-closed-tabs.test.ts | 1 - .../repos-onboarding-folder-startup.test.ts | 13 +- .../slices/store-cascades-test-harness.ts | 2 - .../slices/store-session-test-harness.ts | 3 +- ...tab-worktree-orphan-map-purge-leak.test.ts | 1 - .../store/slices/tabs-slice-test-harness.ts | 3 +- .../store/slices/terminals-hydration.test.ts | 1 - .../workspace-chat-draft-removal.test.ts | 2 +- .../src/store/slices/worktree-helpers.ts | 2 +- .../slices/worktree-removal-maps-leak.test.ts | 1 - .../worktrees-fetch-listing-merge.test.ts | 18 +- .../worktrees-fetch-remote-lineage.test.ts | 6 +- .../worktrees-fetch-removal-purge.test.ts | 11 +- ...orktrees-git-identity-branch-title.test.ts | 1 - ...-identity-review-clear-persistence.test.ts | 15 +- .../slices/worktrees-hydration-purge.test.ts | 31 +-- .../worktrees-identity-migration.test.ts | 7 +- .../slices/worktrees-slice-test-harness.ts | 1 - .../detected-worktree-provider-request.ts | 18 +- .../web/preload-api/web-runtime-session.ts | 3 - .../web-runtime-worktree-catalog.ts | 21 +- .../web-worktree-attachment-mutations.test.ts | 6 +- .../src/web/preload-api/web-worktrees-api.ts | 16 +- .../web-preload-api-workspace-catalog.test.ts | 172 ++++++--------- tests/e2e/helpers/repo-with-workspaces.ts | 4 +- tests/e2e/helpers/worktree-registration.ts | 2 +- .../manual-worktree-order-persistence.spec.ts | 9 +- tests/e2e/worktree-recent-sort.spec.ts | 2 +- tests/e2e/wsl-move-fence.spec.ts | 20 +- 56 files changed, 423 insertions(+), 863 deletions(-) diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index d496220f4d7..a0762bb5555 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -87,7 +87,7 @@ describe('registerFilesystemHandlers', () => { }) }) - it('uses worktree roots seeded by worktrees:list without rebuilding the cache', async () => { + it('uses worktree roots seeded by worktree listing without rebuilding the cache', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) getStatusMock.mockResolvedValue({ entries: [] }) diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index e2cfd5e2adc..457c1298ee6 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -155,19 +155,10 @@ function scanExpectation( } } -function listDetected(): Promise { - return handlers['worktrees:listDetected'](null, { repoId: REPO_ID }) as Promise +async function listDetected(): Promise { + return await handlers['worktrees:listDetected'](null, { repoId: REPO_ID }) } -const localListingCalls = [ - ['worktrees:listDetected', listDetected], - [ - 'worktrees:list', - () => handlers['worktrees:list'](null, { repoId: REPO_ID }) as Promise - ], - ['worktrees:listAll', () => handlers['worktrees:listAll'](null, undefined) as Promise] -] as const - describe('authoritative local worktree metadata pruning integration', () => { beforeEach(() => { setupWorktreeHandlers() @@ -208,7 +199,11 @@ describe('authoritative local worktree metadata pruning integration', () => { // A worktree deleted outside Orca strands its status row unless the prune retires it. const stalePane = makePaneKey('tab-stale', '11111111-1111-4111-8111-111111111111') const livePane = makePaneKey('tab-live', '22222222-2222-4222-8222-222222222222') - const working = { state: 'working', prompt: 'p', agentType: 'codex' } as const + const working = { + state: 'working', + prompt: 'p', + agentType: 'codex' + } as const agentHookServer.ingestTerminalStatus({ paneKey: stalePane, worktreeId: staleId, @@ -304,48 +299,43 @@ describe('authoritative local worktree metadata pruning integration', () => { expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() }) - it.each(localListingCalls)( - 'skips stale WSL root and lineage side effects after %s caller resumption', - async (channel, listWorktrees) => { - const orphanId = `${REPO_ID}::/workspace/orphan` - let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {} - mockSelectedWslProjectRuntime() - store.getAllWorktreeLineage.mockReturnValue({ - [orphanId]: { - worktreeId: orphanId, - worktreeInstanceId: 'orphan-instance', - parentWorktreeId: `${REPO_ID}::${REPO_PATH}`, - parentWorktreeInstanceId: 'main-instance', - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 0 - } - }) - // Why: the detected listing re-runs an overtaken scan, and that re-run finds the orphan alive, - // so a lineage removal can only come from the overtaken scan's side effects. - listWorktreesMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveScan = resolve as (rows: GitWorktreeInfo[]) => void - }) - ) - .mockImplementation(async () => [worktree(REPO_PATH), worktree('/workspace/orphan')]) - - const pending = listWorktrees() - await Promise.resolve() - resolveScan([worktree(REPO_PATH)]) - queueMicrotask(() => notifyWorktreesChanged(mainWindow as never, REPO_ID)) - await pending - - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - // Only the detected listing re-derives; its fresh re-scan is what registers the roots. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the local repository rows read by this registry check. - expect(isRegisteredWorktreePath(REPO_PATH, store as never)).toBe( - channel === 'worktrees:listDetected' + it('skips stale WSL root and lineage side effects after caller resumption', async () => { + const orphanId = `${REPO_ID}::/workspace/orphan` + let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {} + mockSelectedWslProjectRuntime() + store.getAllWorktreeLineage.mockReturnValue({ + [orphanId]: { + worktreeId: orphanId, + worktreeInstanceId: 'orphan-instance', + parentWorktreeId: `${REPO_ID}::${REPO_PATH}`, + parentWorktreeInstanceId: 'main-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + // Why: the detected listing re-runs an overtaken scan, and that re-run finds the orphan alive, + // so a lineage removal can only come from the overtaken scan's side effects. + listWorktreesMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveScan = resolve as (rows: GitWorktreeInfo[]) => void + }) ) - } - ) + .mockImplementation(async () => [worktree(REPO_PATH), worktree('/workspace/orphan')]) + + const pending = listDetected() + await Promise.resolve() + resolveScan([worktree(REPO_PATH)]) + queueMicrotask(() => notifyWorktreesChanged(mainWindow as never, REPO_ID)) + await pending + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + // The detected listing's fresh re-scan is what registers the roots. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the local repository rows read by this registry check. + expect(isRegisteredWorktreePath(REPO_PATH, store as never)).toBe(true) + }) it('preserves WSL roots and lineage created while an older Git scan is pending', async () => { const newPath = '/workspace/new-wsl-worktree' @@ -493,7 +483,9 @@ describe('authoritative local worktree metadata pruning integration', () => { await listDetected() - expect(listWorktreesMock).toHaveBeenCalledWith(REPO_PATH, { wslDistro: 'Ubuntu' }) + expect(listWorktreesMock).toHaveBeenCalledWith(REPO_PATH, { + wslDistro: 'Ubuntu' + }) expect(store.captureNativeLocalWorktreeMetadataScanExpectation).not.toHaveBeenCalled() expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index 4a475fa3812..3df75d02671 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -100,8 +100,11 @@ function remove(args: Record): Promise { type ListedRow = { id: string; removing?: true } async function listRepoRows(): Promise { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:list resolves the repo's Worktree rows. - return (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as ListedRow[] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a repo-scoped worktrees:listDetected resolves { worktrees: Worktree rows }. + const result = (await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })) as { + worktrees: ListedRow[] + } + return result.worktrees } function blockGitRemove(): { diff --git a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts index 59820525df6..320595d21d4 100644 --- a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts +++ b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts @@ -51,7 +51,7 @@ import { getDefaultWorkspaceSession } from '../../shared/constants' const path = '/remote/repository' const rows = [{ path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }] -const channels = ['legacyDetected', 'hostDetected', 'list', 'listAll', 'known'] as const +const channels = ['legacyDetected', 'hostDetected', 'known'] as const function fixture(fields: Pick) { const repo = hydrateRepo( { id: 'repo', path, displayName: 'repo', badgeColor: '#000', addedAt: 0, ...fields }, @@ -104,10 +104,7 @@ function invoke(channel: (typeof channels)[number]) { if (channel === 'hostDetected') { return mocks.handlers.get('worktrees:listDetected')!(null, hostArgs) } - if (channel === 'known') { - return mocks.handlers.get('worktrees:listKnownForExecutionHost')!(null, hostArgs) - } - return mocks.handlers.get(`worktrees:${channel}`)!(null, { repoId: 'repo' }) + return mocks.handlers.get('worktrees:listKnownForExecutionHost')!(null, hostArgs) } beforeEach(() => { vi.clearAllMocks() @@ -133,10 +130,8 @@ describe.each([ }) } else if (channel === 'known') { expect(result).toMatchObject({ status: 'complete', result: { authoritative: false } }) - } else if (channel === 'legacyDetected') { - expect(result).toMatchObject({ authoritative: false }) } else { - expect(result).toEqual([]) + expect(result).toMatchObject({ authoritative: false }) } expect(mocks.git).not.toHaveBeenCalled() expect(mocks.roots).not.toHaveBeenCalled() diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 412c187ac74..1a8fde8a393 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -13,7 +13,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { ORIGIN_HEAD_COMPONENT, ORIGIN_REMOTE_URL, - makeWorktreeMeta + makeWorktreeMeta, + expectDetectedWorktreeListResult } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { @@ -22,8 +23,6 @@ import { } from '../../shared/worktree/host-qualified-identity' const WORKTREE_HANDLER_CHANNELS = [ - 'worktrees:listAll', - 'worktrees:list', 'worktrees:listRetiredNames', 'worktrees:listDetected', 'worktrees:listKnownForExecutionHost', @@ -170,7 +169,9 @@ describe('registerWorktreeHandlers', () => { id === liveId ? ({ instanceId: 'x' } as never) : undefined ) - handlers['worktrees:persistSortOrder'](null, { orderedIds: [liveId, staleId] }) + handlers['worktrees:persistSortOrder'](null, { + orderedIds: [liveId, staleId] + }) const orderedTargets = store.setWorktreeMeta.mock.calls.map((call) => call[0].id) expect(orderedTargets).toContain(liveId) @@ -184,7 +185,9 @@ describe('registerWorktreeHandlers', () => { sortOrder: id === firstId ? 200 : 100 })) - handlers['worktrees:persistSortOrder'](null, { orderedIds: [firstId, secondId] }) + handlers['worktrees:persistSortOrder'](null, { + orderedIds: [firstId, secondId] + }) expect(store.setWorktreeMeta).not.toHaveBeenCalled() }) @@ -199,12 +202,18 @@ describe('registerWorktreeHandlers', () => { isPinned: true, orcaCreatedAt: 123, orcaCreationSource: 'desktop', - orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false } + orcaCreationWorkspaceLayout: { + path: '/workspace', + nestWorkspaces: false + } } }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'local' }), + hostWorktreeTarget({ + id: 'repo-1::/workspace/feature-wt', + hostId: 'local' + }), { comment: 'keep me', isPinned: true } ) expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) @@ -219,7 +228,10 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledExactlyOnceWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'ssh:build-box' }), + hostWorktreeTarget({ + id: 'repo-1::/workspace/feature-wt', + hostId: 'ssh:build-box' + }), { comment: 'remote note' } ) expect(result).toMatchObject({ comment: 'remote note' }) @@ -298,8 +310,12 @@ describe('registerWorktreeHandlers', () => { updates: { isUnread: true } }) - const rows = await handlers['worktrees:list'](null, { repoId: folderRepo.id }) - expect(rows).not.toContainEqual(expect.objectContaining({ id: workspaceId })) + const result = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: folderRepo.id + }) + ) + expect(result.worktrees).not.toContainEqual(expect.objectContaining({ id: workspaceId })) expect(table[workspaceId]).toBeUndefined() }) @@ -327,7 +343,10 @@ describe('registerWorktreeHandlers', () => { updates: { lastActivityAt: 2 } }) - expect(table[workspaceId]).toMatchObject({ displayName: 'Feature work', lastActivityAt: 2 }) + expect(table[workspaceId]).toMatchObject({ + displayName: 'Feature work', + lastActivityAt: 2 + }) }) }) @@ -340,8 +359,14 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'local' }), - expect.objectContaining({ displayName: 'Agent label', displayNameIsPinned: true }) + hostWorktreeTarget({ + id: 'repo-1::/workspace/feature-wt', + hostId: 'local' + }), + expect.objectContaining({ + displayName: 'Agent label', + displayNameIsPinned: true + }) ) }) @@ -399,7 +424,10 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), + hostWorktreeTarget({ + id: 'repo-1::/workspace/improve-dashboard', + hostId: 'local' + }), expect.objectContaining({ displayName: 'Fix: dashboards for PRs' }) @@ -430,7 +458,10 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( hostWorktreeTarget({ id: 'repo-1::/workspace/feature', hostId: 'local' }), - expect.objectContaining({ displayName: 'feature', displayNameIsPinned: true }) + expect.objectContaining({ + displayName: 'feature', + displayNameIsPinned: true + }) ) }) @@ -456,7 +487,10 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), + hostWorktreeTarget({ + id: 'repo-1::/workspace/improve-dashboard', + hostId: 'local' + }), expect.objectContaining({ linkedIssue: 123, linkedPR: 456, @@ -493,7 +527,10 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), + hostWorktreeTarget({ + id: 'repo-1::/workspace/improve-dashboard', + hostId: 'local' + }), expect.objectContaining({ createdWithAgent: 'codex' }) @@ -563,7 +600,10 @@ describe('registerWorktreeHandlers', () => { // Exact object, not objectContaining: `remoteCreated` must stay absent until // something actually mints the remote. expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), + hostWorktreeTarget({ + id: 'repo-1::/workspace/improve-dashboard', + hostId: 'local' + }), expect.objectContaining({ pushTarget: { remoteName: 'pr-prateek-orca', @@ -595,7 +635,9 @@ describe('registerWorktreeHandlers', () => { remoteCreated: true } store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/previous-fix': makeWorktreeMeta({ pushTarget: existingPushTarget }) + 'repo-1::/workspace/previous-fix': makeWorktreeMeta({ + pushTarget: existingPushTarget + }) }) store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -603,7 +645,10 @@ describe('registerWorktreeHandlers', () => { return { stdout: 'pr-contributor-orca\n', stderr: '' } } if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + return { + stdout: 'https://github.com/contributor/orca.git\n', + stderr: '' + } } return { stdout: '', stderr: '' } }) @@ -623,7 +668,10 @@ describe('registerWorktreeHandlers', () => { expect.any(Object) ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), + hostWorktreeTarget({ + id: 'repo-1::/workspace/improve-dashboard', + hostId: 'local' + }), expect.objectContaining({ pushTarget: { remoteName: 'pr-contributor-orca', diff --git a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts index a1e1c0c49cf..8fb34218774 100644 --- a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts +++ b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { makeWorktreeMeta } from './worktrees-test-fixtures' +import { makeWorktreeMeta, expectDetectedWorktreeListResult } from './worktrees-test-fixtures' import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => @@ -106,10 +106,11 @@ describe('registerWorktreeHandlers', () => { } store.setWorktreeMeta.mockReturnValue(stampedMeta) - const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { - id: string - lastActivityAt: number - }[] + const { worktrees: listed } = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + }) + ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( hostWorktreeTarget({ id: 'repo-1::/workspace/discovered-wt', hostId: 'local' }), @@ -157,13 +158,11 @@ describe('registerWorktreeHandlers', () => { lastActivityAt: 42 }) - const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { - id: string - lastActivityAt: number - projectId?: string - hostId?: string - projectHostSetupId?: string - }[] + const { worktrees: listed } = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + }) + ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( hostWorktreeTarget({ id: 'repo-1::/workspace/existing-wt', hostId: 'local' }), @@ -229,13 +228,11 @@ describe('registerWorktreeHandlers', () => { lastActivityAt: 42 }) - const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { - id: string - lastActivityAt: number - projectId?: string - hostId?: string - projectHostSetupId?: string - }[] + const { worktrees: listed } = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + }) + ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( hostWorktreeTarget({ id: 'repo-1::/workspace/existing-wt', hostId: 'local' }), @@ -283,8 +280,8 @@ describe('registerWorktreeHandlers', () => { linkedPR: null, instanceId: 'existing-instance', projectId: 'github:other/project', - hostId: 'ssh:ssh-target-1', - projectHostSetupId: 'repo-other-host', + hostId: 'local', + projectHostSetupId: 'repo-other-setup', isArchived: false, isUnread: false, isPinned: false, @@ -292,8 +289,15 @@ describe('registerWorktreeHandlers', () => { lastActivityAt: 42 }) - await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + const result = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + ) + expect(result.authoritative).toBe(true) + expect(result.worktrees[0]).toMatchObject({ + id: 'repo-1::/workspace/existing-wt', + projectId: 'github:other/project' + }) expect(store.setWorktreeMeta).not.toHaveBeenCalled() }) @@ -306,6 +310,7 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'ssh-target-1' } + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.getAllWorktreeMeta.mockReturnValue({ 'repo-ssh::/remote/orca': makeWorktreeMeta({ @@ -340,13 +345,11 @@ describe('registerWorktreeHandlers', () => { }) ) - const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as { - id: string - projectId?: string - hostId?: string - projectHostSetupId?: string - lastActivityAt: number - }[] + const { worktrees: listed } = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-ssh' + }) + ) expect(listWorktreesMock).not.toHaveBeenCalled() expect(store.setWorktreeMeta).toHaveBeenCalledWith( @@ -392,8 +395,14 @@ describe('registerWorktreeHandlers', () => { lastActivityAt: 42 }) - await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + const result = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + ) + expect(result.authoritative).toBe(true) + expect(result.worktrees.map((worktree) => worktree.id)).toContain( + 'repo-1::/workspace/existing-wt' + ) expect(store.setWorktreeMeta).not.toHaveBeenCalled() }) @@ -426,10 +435,11 @@ describe('registerWorktreeHandlers', () => { lastActivityAt: 42 }) - const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { - id: string - instanceId?: string - }[] + const { worktrees: listed } = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + }) + ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( hostWorktreeTarget({ id: 'repo-1::/workspace/existing-wt', hostId: 'local' }), @@ -476,7 +486,7 @@ describe('registerWorktreeHandlers', () => { lastActivityAt: 1_700_000_000_000 }) - await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( hostWorktreeTarget({ id: 'repo-1::/workspace/folder', hostId: 'local' }), @@ -489,36 +499,7 @@ describe('registerWorktreeHandlers', () => { ) }) - it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => { - // Why: stamping logic is duplicated in worktrees:list and worktrees:listAll; a listAll regression would silently bury newly-discovered worktrees. - listWorktreesMock.mockResolvedValue([ - { - path: '/workspace/discovered-wt', - head: 'abc123', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) - store.getWorktreeMeta.mockReturnValue(undefined) - store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) - - const listed = (await handlers['worktrees:listAll'](null, undefined)) as { - id: string - lastActivityAt: number - }[] - - expect(store.setWorktreeMeta).toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-1::/workspace/discovered-wt', hostId: 'local' }), - expect.objectContaining({ lastActivityAt: expect.any(Number) }) - ) - expect(listed[0]).toMatchObject({ - id: 'repo-1::/workspace/discovered-wt', - lastActivityAt: 1_700_000_000_000 - }) - }) - - it('omits prunable worktrees from worktrees:listAll', async () => { + it('omits prunable worktrees from worktrees:listDetected', async () => { // Why: a prunable registration has no working directory (issue #8389), so surfacing it yields repeated pty/fs failures and a blank pane. listWorktreesMock.mockResolvedValue([ { @@ -548,87 +529,14 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(undefined) store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) - const listed = (await handlers['worktrees:listAll'](null, undefined)) as { id: string }[] + const { worktrees: listed } = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + }) + ) const listedIds = listed.map((worktree) => worktree.id) expect(listedIds).toContain('repo-1::/workspace/live-wt') expect(listedIds).not.toContain('repo-1::/workspace/stale-wt') }) - - it('limits concurrent repo scans in worktrees:listAll while preserving order', async () => { - const repos = Array.from({ length: 10 }, (_, index) => ({ - id: `repo-${index}`, - path: `/workspace/repo-${index}`, - displayName: `repo-${index}`, - badgeColor: '#000', - addedAt: 0 - })) - store.getRepos.mockReturnValue(repos) - let activeScans = 0 - let maxActiveScans = 0 - let notifyScanStarted: (() => void) | undefined - const waitForScanCount = async (count: number): Promise => { - while (listWorktreesMock.mock.calls.length < count) { - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`Timed out waiting for ${count} scans`)), - 1000 - ) - notifyScanStarted = () => { - clearTimeout(timeout) - resolve() - } - }) - } - } - const pendingScans: (() => void)[] = [] - listWorktreesMock.mockImplementation( - async ( - repoPath: string - ): Promise< - { path: string; head: string; branch: string; isBare: false; isMainWorktree: true }[] - > => { - activeScans += 1 - maxActiveScans = Math.max(maxActiveScans, activeScans) - await new Promise((resolve) => { - pendingScans.push(resolve) - notifyScanStarted?.() - notifyScanStarted = undefined - }) - activeScans -= 1 - return [ - { - path: repoPath, - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ] - } - ) - - const listPromise = handlers['worktrees:listAll'](null, undefined) as Promise< - { path: string }[] - > - await Promise.resolve() - - expect(listWorktreesMock).toHaveBeenCalledTimes(8) - expect(maxActiveScans).toBe(8) - - for (const resolve of pendingScans.splice(0)) { - resolve() - } - await waitForScanCount(10) - - expect(listWorktreesMock).toHaveBeenCalledTimes(10) - - for (const resolve of pendingScans.splice(0)) { - resolve() - } - const listed = await listPromise - - expect(maxActiveScans).toBe(8) - expect(listed.map((worktree) => worktree.path)).toEqual(repos.map((repo) => repo.path)) - }) }) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 4d47249fc9c..79397e4c9c4 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -1,5 +1,5 @@ // Desktop IPC for a delete that failed after Git dropped the registration: the leftover stays in -// `worktrees:list` with the error; Delete refuses while it is on disk and finishes once it is gone. Git and the disk are mocked; +// `worktrees:listDetected` with the error; Delete refuses while it is on disk and finishes once it is gone. Git and the disk are mocked; // runtime-failed-local-worktree-removal.test.ts runs the real thing. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -11,7 +11,7 @@ import { import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { Worktree } from '../../shared/worktree/types' +import type { DetectedWorktree, DetectedWorktreeListResult } from '../../shared/worktree/types' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' import { worktreeCheckoutExists } from '../worktree-removal-table' import type * as WorktreeRemovalModule from '../git/worktree-removal' @@ -124,10 +124,12 @@ function remove(args: Record): Promise { return handlers['worktrees:remove'](null, args) as Promise } -async function listFeature(): Promise { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:list resolves the repo's Worktree rows. - const rows = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as Worktree[] - return rows.find((row) => row.id === featureId) +async function listFeature(): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a repo-scoped worktrees:listDetected resolves a DetectedWorktreeListResult. + const result = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as DetectedWorktreeListResult + return result.worktrees.find((row) => row.id === featureId) } /** Git fails partway and drops the registration, as `git worktree remove --force` does. */ diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index e23f0fd07f5..f2e63855696 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import type { Worktree } from '../../shared/worktree/types' import { listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { makeWorktreeMeta } from './worktrees-test-fixtures' +import { makeWorktreeMeta, expectDetectedWorktreeListResult } from './worktrees-test-fixtures' import { hostWorktreeTarget, type HostWorktreeTarget @@ -135,15 +134,19 @@ describe('registerWorktreeHandlers', () => { } ]) - const result = (await handlers['worktrees:listDetected'](null, { - repoId: 'repo-1' - })) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] } + const result = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + }) + ) expect(result.worktrees).toEqual([ expect.objectContaining({ id: childId, parentWorktreeId: parentId, - lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + lineage: expect.objectContaining({ + parentWorktreeInstanceId: 'parent-instance' + }) }), expect.objectContaining({ id: parentId, @@ -195,9 +198,11 @@ describe('registerWorktreeHandlers', () => { } }) - const result = (await handlers['worktrees:listDetected'](null, { - repoId: folderRepo.id - })) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] } + const result = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: folderRepo.id + }) + ) expect(result.worktrees).toEqual([ expect.objectContaining({ @@ -209,7 +214,9 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: childId, parentWorktreeId: parentId, - lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + lineage: expect.objectContaining({ + parentWorktreeInstanceId: 'parent-instance' + }) }) ]) }) @@ -241,19 +248,19 @@ describe('registerWorktreeHandlers', () => { } ]) - const detected = (await handlers['worktrees:listDetected'](null, { - repoId: 'repo-1' - })) as { worktrees: (Worktree & { ownership: string; visible: boolean })[] } - const visible = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as Worktree[] + const detected = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + }) + ) expect(detected.worktrees.find((worktree) => worktree.path === scratchPath)).toMatchObject({ ownership: 'agent-scratch', visible: false }) - expect(visible.map((worktree) => worktree.path)).toEqual([ - '/workspace/repo', - linkedCheckoutPath - ]) + expect( + detected.worktrees.filter((worktree) => worktree.visible).map((worktree) => worktree.path) + ).toEqual(['/workspace/repo', linkedCheckoutPath]) }) it('does not repeatedly rotate already-invalid missing parent metadata', async () => { @@ -277,12 +284,17 @@ describe('registerWorktreeHandlers', () => { parentWorktreeInstanceId: 'old-parent-instance' } }) - store.getWorktreeMeta.mockReturnValue({ instanceId: 'rotated-parent-instance' }) + store.getWorktreeMeta.mockReturnValue({ + instanceId: 'rotated-parent-instance' + }) - await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + await handlers['worktrees:listDetected'](null, { repoId: 'repo-ssh' }) expect(store.setWorktreeMeta).not.toHaveBeenCalledWith( - hostWorktreeTarget({ id: 'repo-ssh::/remote/missing-parent', hostId: 'local' }), + hostWorktreeTarget({ + id: 'repo-ssh::/remote/missing-parent', + hostId: 'local' + }), expect.objectContaining({ instanceId: expect.any(String) }) ) }) diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index e06688f5d2d..b1126ff9fe6 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { makeWorktreeMeta } from './worktrees-test-fixtures' +import { makeWorktreeMeta, expectDetectedWorktreeListResult } from './worktrees-test-fixtures' import { buildDetectedGitWorktrees } from './worktrees/listing/ssh-worktree-fallback' import type { Store } from '../persistence/loading-store/store' import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' +import type { DetectedWorktree } from '../../shared/worktree/types' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -83,6 +84,15 @@ vi.mock('./pty/provider/state-cleanup', async () => (await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock() ) +async function listDetectedRows(repoId: string): Promise { + const result = expectDetectedWorktreeListResult( + await handlers['worktrees:listDetected'](null, { + repoId + }) + ) + return result.worktrees +} + describe('registerWorktreeHandlers', () => { beforeEach(() => { setupWorktreeHandlers() @@ -123,7 +133,7 @@ describe('registerWorktreeHandlers', () => { worktreeId === rootWorktreeId ? rootMeta : undefined ) - const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + const listed = await listDetectedRows('repo-1') expect(listed).toEqual([ expect.objectContaining({ @@ -140,13 +150,6 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) - it('fails closed when the renderer has not selected a repo yet', async () => { - const listed = await handlers['worktrees:list'](null, undefined) - - expect(listed).toEqual([]) - expect(store.getRepo).not.toHaveBeenCalled() - }) - it('returns reconstructed rows when an SSH provider is unavailable', async () => { const repo = { id: 'repo-ssh', @@ -156,6 +159,7 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'conn-1' } + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.getAllWorktreeMeta.mockReturnValue({ 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ @@ -187,7 +191,7 @@ describe('registerWorktreeHandlers', () => { }) }) - const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + const listed = await listDetectedRows('repo-ssh') expect(listed).toEqual([ expect.objectContaining({ @@ -221,7 +225,6 @@ describe('registerWorktreeHandlers', () => { ] }) ]) - expect(store.getWorktreeMeta).not.toHaveBeenCalled() expect(store.setWorktreeMeta).toHaveBeenCalledWith( hostWorktreeTarget({ id: 'repo-ssh::/remote/feature-wt', hostId: 'ssh:conn-1' }), { @@ -246,6 +249,7 @@ describe('registerWorktreeHandlers', () => { displayName: 'Canonical remote', hostId: 'ssh:conn-1' }) + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.getAllWorktreeMeta.mockReturnValue({ [worktreeId]: makeWorktreeMeta({ displayName: 'Legacy local', hostId: 'local' }) @@ -256,7 +260,7 @@ describe('registerWorktreeHandlers', () => { } ).getAllWorktreeMetaForHost = vi.fn(() => ({ [worktreeId]: canonicalRemote })) - const listed = await handlers['worktrees:list'](null, { repoId: repo.id }) + const listed = await listDetectedRows(repo.id) expect(listed).toEqual([ expect.objectContaining({ @@ -276,6 +280,7 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'conn-1' } + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.getAllWorktreeMeta.mockReturnValue({ 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ @@ -284,7 +289,7 @@ describe('registerWorktreeHandlers', () => { }) }) - const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + const listed = await listDetectedRows('repo-ssh') expect(listWorktreesMock).not.toHaveBeenCalled() expect(listed).toEqual([ @@ -296,20 +301,6 @@ describe('registerWorktreeHandlers', () => { ]) }) - it('keeps local listing failure behavior as an empty list', async () => { - listWorktreesMock.mockRejectedValue(new Error('filesystem denied')) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ - displayName: 'Should not appear' - }) - }) - - const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' }) - - expect(listed).toEqual([]) - expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() - }) - it('ignores malformed metadata keys during SSH fallback', async () => { const repo = { id: 'repo-ssh', @@ -319,13 +310,14 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'conn-1' } + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.getAllWorktreeMeta.mockReturnValue({ 'not-a-worktree-id': makeWorktreeMeta({ displayName: 'Bad row' }), 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Good row' }) }) - const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + const listed = await listDetectedRows('repo-ssh') expect(listed).toEqual([ expect.objectContaining({ @@ -344,6 +336,7 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'conn-1' } + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.getAllWorktreeMeta.mockReturnValue({ 'repo-ssh::/remote/custom-name': makeWorktreeMeta({ @@ -351,11 +344,7 @@ describe('registerWorktreeHandlers', () => { }) }) - const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as { - displayName: string - isSparse?: boolean - sparseDirectories?: string[] - }[] + const listed = await listDetectedRows('repo-ssh') expect(listed[0]).toMatchObject({ displayName: 'custom-name', @@ -373,97 +362,17 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'conn-1' } + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.getAllWorktreeMeta.mockReturnValue({ 'repo-ssh::c:/remote/repo': makeWorktreeMeta() }) - const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as { - isMainWorktree: boolean - }[] + const listed = await listDetectedRows('repo-ssh') expect(listed[0].isMainWorktree).toBe(true) }) - it('includes SSH fallback rows in listAll alongside healthy local rows', async () => { - const sshRepo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'SSH Repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1' - } - const localRepo = { - id: 'repo-local', - path: '/workspace/local', - displayName: 'Local Repo', - badgeColor: '#111', - addedAt: 0 - } - store.getRepos.mockReturnValue([sshRepo, localRepo]) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Remote cached' }) - }) - listWorktreesMock.mockResolvedValue([ - { - path: '/workspace/local', - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - - const listed = await handlers['worktrees:listAll'](null, undefined) - - expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1) - expect(listed).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'repo-ssh::/remote/feature-wt', - displayName: 'Remote cached' - }), - expect.objectContaining({ - id: 'repo-local::/workspace/local', - branch: 'refs/heads/main' - }) - ]) - ) - }) - - it('snapshots SSH fallback metadata once for listAll', async () => { - const sshRepoA = { - id: 'repo-ssh-a', - path: '/remote/a', - displayName: 'SSH A', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1' - } - const sshRepoB = { - id: 'repo-ssh-b', - path: '/remote/b', - displayName: 'SSH B', - badgeColor: '#111', - addedAt: 0, - connectionId: 'conn-2' - } - store.getRepos.mockReturnValue([sshRepoA, sshRepoB]) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-ssh-a::/remote/a/one': makeWorktreeMeta({ displayName: 'One' }), - 'repo-ssh-b::/remote/b/two': makeWorktreeMeta({ displayName: 'Two' }) - }) - - const listed = await handlers['worktrees:listAll'](null, undefined) - - expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1) - expect(listed).toEqual([ - expect.objectContaining({ id: 'repo-ssh-a::/remote/a/one' }), - expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' }) - ]) - }) - it('loads repo ownership once while building multiple detected rows', () => { const owner = { id: 'repo-1', diff --git a/src/main/ipc/worktrees-test-fixtures.ts b/src/main/ipc/worktrees-test-fixtures.ts index f9f00d6756a..0b8c9ff1d0d 100644 --- a/src/main/ipc/worktrees-test-fixtures.ts +++ b/src/main/ipc/worktrees-test-fixtures.ts @@ -1,4 +1,4 @@ -import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { DetectedWorktreeListResult, GitWorktreeInfo } from '../../shared/worktree/types' import { reviewHeadRemoteRefComponent } from '../../shared/review-head-tracking-ref' import { listWorktreesMock, setPlatform } from './worktrees-test-module-mocks' import { store } from './worktrees-test-ipc-surface' @@ -71,3 +71,21 @@ export function mockSelectedWslProjectRuntime(): void { } ]) } + +function isDetectedWorktreeListResult(value: unknown): value is DetectedWorktreeListResult { + return ( + typeof value === 'object' && + value !== null && + 'authoritative' in value && + typeof value.authoritative === 'boolean' && + 'worktrees' in value && + Array.isArray(value.worktrees) + ) +} + +export function expectDetectedWorktreeListResult(value: unknown): DetectedWorktreeListResult { + if (!isDetectedWorktreeListResult(value)) { + throw new Error(`Expected a detected worktree list result, got ${JSON.stringify(value)}`) + } + return value +} diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index f9bc308bfab..3e619bc1652 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -517,7 +517,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { isMainWorktree: false } // Two calls: (1) worktrees:create finds the new worktree, - // (2) worktrees:list enumerates worktrees again. + // (2) worktrees:listDetected enumerates worktrees again. listWorktreesMock.mockResolvedValueOnce([worktreeEntry]).mockResolvedValueOnce([worktreeEntry]) store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 123, @@ -550,18 +550,18 @@ describe('registerWorktreeHandlers – Windows path handling', () => { linkedIssue: 123, linkedPR: 456 }) - const listed = await handlers['worktrees:list'](null, { + const listed = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) - expect(listed).toMatchObject([ - { + expect(listed).toHaveProperty('worktrees', [ + expect.objectContaining({ id: 'repo-1::C:/workspaces/improve-dashboard', displayName: 'Improve Dashboard', linkedIssue: 123, linkedPR: 456, lastActivityAt: 123 - } + }) ]) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 5ae2263e814..69d3420e062 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -30,8 +30,6 @@ registerDetectedWorktreeScanInvalidation() let disposeSparseCheckoutCacheInvalidation: (() => void) | undefined const WORKTREE_HANDLER_CHANNELS = [ - 'worktrees:listAll', - 'worktrees:list', 'worktrees:listRetiredNames', 'worktrees:listDetected', 'worktrees:listKnownForExecutionHost', diff --git a/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts index c3c51ac35c1..e099d300a9b 100644 --- a/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts +++ b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts @@ -108,21 +108,6 @@ export function buildFolderDetectedWorktrees(store: Store, repo: Repo): Detected ) } -export function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { - const writeMeta = folderMetaWriter(store, repo) - return buildFolderDetectedWorktrees(store, repo) - .filter((worktree) => worktree.visible) - .map((worktree) => { - const meta = store.getWorktreeMeta(worktree.id) - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) - const repairedMeta = - meta && Object.keys(ownershipUpdates).length === 0 - ? meta - : writeMeta(worktree.id, ownershipUpdates) - return mergeFolderWorkspace(repo, worktree.id, repairedMeta) - }) -} - export function buildDisconnectedDetectedWorktrees( store: Store, repo: Repo, diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 778e9fe2e5f..2d26739e77d 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -1,157 +1,11 @@ import { ipcMain } from 'electron' -import { withoutWslMovedRepos } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' -import { isFolderRepo } from '../../../../shared/repo-kind' -import { - getRepoExecutionHostId, - getRepoDialableSshTargetId, - type ExecutionHostId -} from '../../../../shared/execution-host' import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' import { getRetiredNameRegistryForRepo } from '../../../worktree-name-retirement' -import { - buildDetectedGitWorktrees, - createSshWorktreeMetaIndex, - listDisconnectedSshWorktrees, - stampAndMergeVisibleDetectedWorktree -} from './ssh-worktree-fallback' -import { listVisibleFolderWorkspaces } from './folder-workspace-catalog' -import { - applyFreshDetectedWorktreeScanSideEffects, - listDetectedGitWorktrees -} from './detected-worktree-scan-cache' -import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics' import type { WorktreeIpcContext } from '../worktree-ipc-context' -import { - readAllWorktreeMetaForHost, - readAllWorktreeMetaForRepo -} from '../../../persistence/host-qualified-worktree-meta' -import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { - projectPendingWorktreeRemovals, - snapshotPendingWorktreeRemovals, - withUnregisteredRemovalCheckouts, - type PendingWorktreeRemovals -} from '../../../worktree-removal-listing' -import { withoutInFlightWorktreeCreates } from '../../../worktree-create-in-flight' - -const WORKTREE_LIST_ALL_CONCURRENCY = 8 - -// Why always marked: the desktop renderer ships with this main process, so it reads the marker. -function markLocalWorktreesUnderRemoval( - worktrees: T[], - pendingAtScan: PendingWorktreeRemovals -): T[] { - return projectPendingWorktreeRemovals(worktrees, (worktree) => worktree.id, true, pendingAtScan) -} - -async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workerCount = Math.min(limit, items.length) - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await fn(items[index]) - } - }) - ) - return results -} export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): void { const { store } = context - ipcMain.handle('worktrees:listAll', async () => { - // A repo the WSL host owns is listed by the host; this build must not scan its old path. - const repos = withoutWslMovedRepos(store.getRepos()) - const legacyMetadata = - typeof store.getAllWorktreeMetaForHost === 'function' ? undefined : store.getAllWorktreeMeta() - const metadataByHost = new Map>() - const metadataForRepo = (repo: (typeof repos)[number]): Record => { - const hostId = getRepoExecutionHostId(repo) - const cached = metadataByHost.get(hostId) - if (cached) { - return cached - } - const metadata = - typeof store.getAllWorktreeMetaForHost === 'function' - ? store.getAllWorktreeMetaForHost(hostId) - : readAllWorktreeMetaForHost({ getAllWorktreeMeta: () => legacyMetadata ?? {} }, hostId) - metadataByHost.set(hostId, metadata) - return metadata - } - const sshMetaIndexByHost = new Map< - ExecutionHostId, - ReturnType - >() - const sshMetaIndexForRepo = (repo: (typeof repos)[number]) => { - const hostId = getRepoExecutionHostId(repo) - const cached = sshMetaIndexByHost.get(hostId) - if (cached) { - return cached - } - const index = createSshWorktreeMetaIndex(Object.entries(metadataForRepo(repo))) - sshMetaIndexByHost.set(hostId, index) - return index - } - - // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. - const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { - const connectionId = getRepoDialableSshTargetId(repo) - try { - const pendingAtScan = snapshotPendingWorktreeRemovals() - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } - // An `ssh:` repo is not served here, so it lists from metadata, never from this machine. - if (connectionId) { - return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo)) - } - const scan = await listDetectedGitWorktrees(store, repo) - if (scan.fresh) { - await applyFreshDetectedWorktreeScanSideEffects( - store, - repo, - scan.gitWorktrees, - scan.metadataPrune, - { - sideEffectToken: scan.sideEffectToken, - ...(scan.hygieneDue === undefined ? {} : { hygieneDue: scan.hygieneDue }) - } - ) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - const metadata = metadataForRepo(repo) - const rows = await withUnregisteredRemovalCheckouts( - repo.id, - withoutInFlightWorktreeCreates(repo.id, scan.gitWorktrees) - ) - const worktrees = buildDetectedGitWorktrees(store, repo, rows, metadata) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) - return markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated. - return [] - } - }) - - return results.flat() - }) - ipcMain.handle('worktrees:listRetiredNames', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo) { @@ -159,64 +13,4 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) }) - - ipcMain.handle('worktrees:list', async (_event, args: { repoId: string } | undefined) => { - // Renderer startup can race repo selection; malformed requests must fail closed, not crash the handler. - const repoId = typeof args?.repoId === 'string' ? args.repoId : '' - if (!repoId) { - return [] - } - const repo = store.getRepo(repoId) - if (!repo) { - return [] - } - const connectionId = getRepoDialableSshTargetId(repo) - const allMeta = connectionId ? readAllWorktreeMetaForRepo(store, repo) : undefined - const sshWorktreeMetaIndex = connectionId - ? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {})) - : new Map() - - try { - const pendingAtScan = snapshotPendingWorktreeRemovals() - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } - // An `ssh:` repo is not served here, so it lists from metadata, never from this machine. - if (connectionId) { - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - const scan = await listDetectedGitWorktrees(store, repo) - if (scan.fresh) { - await applyFreshDetectedWorktreeScanSideEffects( - store, - repo, - scan.gitWorktrees, - scan.metadataPrune, - { - sideEffectToken: scan.sideEffectToken, - ...(scan.hygieneDue === undefined ? {} : { hygieneDue: scan.hygieneDue }) - } - ) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo) - const rows = await withUnregisteredRemovalCheckouts( - repo.id, - withoutInFlightWorktreeCreates(repo.id, scan.gitWorktrees) - ) - const worktrees = buildDetectedGitWorktrees(store, repo, rows, metadata) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) - return markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees. - return [] - } - }) } diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index 93ef3281b2c..60628a8dbc0 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -203,13 +203,3 @@ export function buildDetectedGitWorktrees( }) return projectWorktreeLineageWithHostPeers(store, repo, detected) } - -export function stampAndMergeVisibleDetectedWorktree( - store: Store, - repo: Repo, - detected: DetectedWorktree, - allMetaOverride?: Record -) { - const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id, allMetaOverride) - return mergeWorktree(repo.id, detected, meta, repo.displayName) -} diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index 7dd3980ee88..0d1aa9d9a75 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -80,7 +80,7 @@ export type RepoWorktreeRowDeps = { /** * Persisted rows for a repo whose scan is unreachable or stalled, so a degraded host publishes what - * it last knew instead of an empty catalog. `worktrees:list` does the same for disconnected SSH. + * it last knew instead of an empty catalog. `worktrees:listDetected` does the same for disconnected SSH. */ export function listStoredWorktreeRowsForRepo( store: RepoWorktreeRowStore, diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts index 4601b6d4df2..fb2ceb7416c 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -1,5 +1,5 @@ -// Why this file exists: the authoritative missing-metadata prune had exactly one caller, -// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept +// Why this file exists: the authoritative missing-metadata prune once ran only from the since-removed +// `worktrees:listAll` IPC handler. A headless runtime host has no renderer, so it never swept // its own repos and their `worktreeMeta` rows grew without bound (#17776). import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 90bb29086d4..33e698c2f52 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -43,7 +43,6 @@ import type { } from '../../shared/worktree/types' export type WorktreeApi = { - list: (args: { repoId: string }) => Promise /** Retired names prevent path reuse; fully spent tiers are compacted into a watermark. */ listRetiredNames: (args: { repoId: string }) => Promise listDetected: { @@ -60,7 +59,6 @@ export type WorktreeApi = { args: ForgetRemovedWorktreesForExecutionHostArgs ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise - listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise /** Local creates report progress by creationId; remote/runtime creates emit nothing. */ diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 52e9fcfe0d1..67b48ba7e4c 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -11,7 +11,6 @@ import type { import type { PreloadApi } from '../api-types' export const worktreesApi = { - list: (args) => ipcRenderer.invoke('worktrees:list', args), listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), @@ -24,8 +23,6 @@ export const worktreesApi = { cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), - listAll: () => ipcRenderer.invoke('worktrees:listAll'), - create: (args) => ipcRenderer.invoke('worktrees:create', args), adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), diff --git a/src/renderer/src/lib/desktop-created-server-tabs.test.ts b/src/renderer/src/lib/desktop-created-server-tabs.test.ts index 36c32698556..23954fa607d 100644 --- a/src/renderer/src/lib/desktop-created-server-tabs.test.ts +++ b/src/renderer/src/lib/desktop-created-server-tabs.test.ts @@ -16,7 +16,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts index 4aee76c86b8..b053ba0d0ad 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts @@ -34,7 +34,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts b/src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts index 84f6b0dc96c..b7e8eec6db7 100644 --- a/src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts +++ b/src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts @@ -29,7 +29,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts index f2a43955bd8..d2662ee4907 100644 --- a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -40,7 +40,7 @@ vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => { }) const mockApi = { - worktrees: { list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined) }, + worktrees: { remove: vi.fn().mockResolvedValue(undefined) }, pty: { kill: vi.fn().mockResolvedValue(undefined) } } diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 730d3feb68c..d3301d18857 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -31,7 +31,6 @@ const mockApi = { set: vi.fn().mockResolvedValue(undefined) }, worktrees: { - list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), updateMeta diff --git a/src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts index 488b9f87e35..11aa830fdf4 100644 --- a/src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts +++ b/src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts @@ -28,7 +28,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts b/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts index f0dcbd5e6c5..a2d3766e9ad 100644 --- a/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts +++ b/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts @@ -29,7 +29,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index 349fe77d104..b53ec11d9e1 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -39,7 +39,6 @@ vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts index 22d9d00e494..80122b4c447 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -23,7 +23,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), updateMeta: vi.fn().mockResolvedValue({}) }, diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index a80f4b24caa..bf7f3b58db5 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -12,6 +12,7 @@ import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY } from '../../../../shared/protocol-version' import { createTestStore, makeWorktree } from './store-test-helpers' +import { makeDetectedResult } from './worktrees-detected-listing-fixtures' const worktreeActivation = vi.hoisted(() => ({ activateAndRevealWorktree: vi.fn() @@ -29,20 +30,20 @@ vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.f afterEach(() => vi.restoreAllMocks()) const reposAdd = vi.fn() -const worktreesList = vi.fn() +const worktreesListDetected = vi.fn() const onboardingGet = vi.fn() const onboardingUpdate = vi.fn() beforeEach(() => { reposAdd.mockReset() - worktreesList.mockReset() + worktreesListDetected.mockReset() onboardingGet.mockReset() onboardingUpdate.mockReset() worktreeActivation.activateAndRevealWorktree.mockReset() vi.stubGlobal('window', { api: { repos: { add: reposAdd }, - worktrees: { list: worktreesList }, + worktrees: { listDetected: worktreesListDetected }, onboarding: { get: onboardingGet, update: onboardingUpdate } } }) @@ -57,9 +58,9 @@ describe('repo slice skipped-onboarding folder startup', () => { .mockResolvedValueOnce({ repo: { id: 'folder-2', path: '/second', displayName: 'Second', addedAt: 2 } }) - worktreesList.mockImplementation(({ repoId }: { repoId: string }) => [ - makeWorktree({ id: `${repoId}::/folder`, repoId }) - ]) + worktreesListDetected.mockImplementation(({ repoId }: { repoId: string }) => + makeDetectedResult(repoId, [makeWorktree({ id: `${repoId}::/folder`, repoId })]) + ) let onboarding: OnboardingState = { ...getDefaultOnboardingState(), outcome: 'dismissed' } onboardingGet.mockImplementation(async () => onboarding) onboardingUpdate.mockImplementation( diff --git a/src/renderer/src/store/slices/store-cascades-test-harness.ts b/src/renderer/src/store/slices/store-cascades-test-harness.ts index 5e731f93484..32dceb87aa4 100644 --- a/src/renderer/src/store/slices/store-cascades-test-harness.ts +++ b/src/renderer/src/store/slices/store-cascades-test-harness.ts @@ -3,7 +3,6 @@ import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/run export type StoreCascadesMockApi = { worktrees: { - list: Mock create: Mock remove: Mock forceDeletePreservedBranch: Mock @@ -21,7 +20,6 @@ export type StoreCascadesMockApi = { export function createStoreCascadesMockApi(): StoreCascadesMockApi { const mockApi: StoreCascadesMockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), diff --git a/src/renderer/src/store/slices/store-session-test-harness.ts b/src/renderer/src/store/slices/store-session-test-harness.ts index 38fdd630c88..3a48b979153 100644 --- a/src/renderer/src/store/slices/store-session-test-harness.ts +++ b/src/renderer/src/store/slices/store-session-test-harness.ts @@ -17,7 +17,7 @@ type UsageScannerMocks = { } export type StoreSessionMockApi = { - worktrees: { list: Mock; create: Mock; remove: Mock; updateMeta: Mock } + worktrees: { create: Mock; remove: Mock; updateMeta: Mock } repos: { list: Mock add: Mock @@ -39,7 +39,6 @@ export type StoreSessionMockApi = { export function createStoreSessionMockApi(): StoreSessionMockApi { const mockApi: StoreSessionMockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts b/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts index 43fe715bdb4..f34cc25fe6b 100644 --- a/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts +++ b/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts @@ -41,7 +41,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/tabs-slice-test-harness.ts b/src/renderer/src/store/slices/tabs-slice-test-harness.ts index 5298bb3cd67..ee1a6c3dd61 100644 --- a/src/renderer/src/store/slices/tabs-slice-test-harness.ts +++ b/src/renderer/src/store/slices/tabs-slice-test-harness.ts @@ -12,7 +12,7 @@ export type UsageScannerMocks = { } export type TabsSliceMockApi = { - worktrees: { list: Mock; create: Mock; remove: Mock; updateMeta: Mock } + worktrees: { create: Mock; remove: Mock; updateMeta: Mock } repos: { list: Mock; add: Mock; remove: Mock; update: Mock; pickFolder: Mock } pty: { kill: Mock; spawn: Mock } gh: { prForBranch: Mock; issue: Mock } @@ -28,7 +28,6 @@ export type TabsSliceMockApi = { export function createTabsSliceMockApi(): TabsSliceMockApi { const mockApi: TabsSliceMockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 81a03446523..431cc1fa162 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -11,7 +11,6 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts index 4ddd81943bb..093979a062e 100644 --- a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts +++ b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts @@ -23,7 +23,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { }) const mockApi = { - worktrees: { list: vi.fn(), remove: vi.fn(), updateMeta: vi.fn().mockResolvedValue({}) }, + worktrees: { remove: vi.fn(), updateMeta: vi.fn().mockResolvedValue({}) }, repos: { removeForHost: vi.fn() }, folderWorkspaces: { delete: vi.fn() }, pty: { kill: vi.fn() }, diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index eaa9799d44b..accb0723f26 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -162,7 +162,7 @@ export type WorktreeSlice = { lastVisitedAtByWorktreeId: Record /** * Guards the one-shot hydration-time purge in `fetchAllWorktrees`. Set to - * `true` only after the first launch where every repo's `worktrees.list` IPC + * `true` only after the first launch where every repo's `worktrees.listDetected` IPC * call succeeded AND at least one repo returned a non-empty result — at that * moment the renderer has enough signal to treat the union of fetched ids as * authoritative and purge stale `tabsByWorktree` keys left behind by pre-fix diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts index d882e98770b..102bec06bc7 100644 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts @@ -44,7 +44,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { - list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), updateMeta: vi.fn().mockResolvedValue({}) diff --git a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts index f3bae6d97f3..40308199560 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts @@ -164,7 +164,7 @@ describe('fetchWorktrees', () => { displayName: 'feature-two' }) - mockApi.worktrees.list.mockResolvedValue([refreshed]) + worktreeListMock.mockResolvedValue([refreshed]) store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial) await store.getState().fetchWorktrees('repo1') @@ -194,7 +194,7 @@ describe('fetchWorktrees', () => { pushTarget: { remoteName: 'origin', branchName: 'feature-one' } }) - mockApi.worktrees.list.mockResolvedValue([refreshed]) + worktreeListMock.mockResolvedValue([refreshed]) store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) await store.getState().fetchWorktrees('repo1') @@ -412,7 +412,7 @@ describe('fetchWorktrees', () => { }) const staleResponse = { ...existing, displayNameMode: undefined } - mockApi.worktrees.list.mockResolvedValueOnce([staleResponse]) + worktreeListMock.mockResolvedValueOnce([staleResponse]) store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) await store.getState().fetchWorktrees('repo1') @@ -437,7 +437,7 @@ describe('fetchWorktrees', () => { // A changed non-branch label from a mode-less host is explicit meta a peer wrote there. const peerRenamed = { ...existing, displayName: 'peer label', displayNameMode: undefined } - mockApi.worktrees.list.mockResolvedValueOnce([peerRenamed]) + worktreeListMock.mockResolvedValueOnce([peerRenamed]) store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) await store.getState().fetchWorktrees('repo1') @@ -462,7 +462,7 @@ describe('fetchWorktrees', () => { displayNameMode: undefined } - mockApi.worktrees.list.mockResolvedValueOnce([rederived]) + worktreeListMock.mockResolvedValueOnce([rederived]) store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) await store.getState().fetchWorktrees('repo1') @@ -486,7 +486,7 @@ describe('fetchWorktrees', () => { }) const rederived = { ...existing, branch: '', displayName: 'wt1', displayNameMode: undefined } - mockApi.worktrees.list.mockResolvedValueOnce([rederived]) + worktreeListMock.mockResolvedValueOnce([rederived]) store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) await store.getState().fetchWorktrees('repo1') @@ -514,7 +514,7 @@ describe('fetchWorktrees', () => { resolvePersist = resolve }) ) - mockApi.worktrees.list.mockResolvedValueOnce([staleResponse]) + worktreeListMock.mockResolvedValueOnce([staleResponse]) store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) const rename = store.getState().updateWorktreeMeta(existing.id, { displayName: 'new label' }) @@ -546,7 +546,7 @@ describe('fetchWorktrees', () => { baseRef: 'upstream/release' }) - mockApi.worktrees.list.mockResolvedValue([refreshed]) + worktreeListMock.mockResolvedValue([refreshed]) store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial) await store.getState().fetchWorktrees('repo1') @@ -569,7 +569,7 @@ describe('fetchWorktrees', () => { priorWorktreeIds: ['repo1::/path/old-name'] }) - mockApi.worktrees.list.mockResolvedValue([refreshed]) + worktreeListMock.mockResolvedValue([refreshed]) store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial) await store.getState().fetchWorktrees('repo1') diff --git a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts index fed26d8850f..284f4198358 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts @@ -19,10 +19,10 @@ function createRemoteRepoStore() { } import { createTestStore, - mockApi, resetRemoteRuntimeMocks, resetWorktreeSliceModuleMemory, - runtimeEnvironmentCall + runtimeEnvironmentCall, + worktreeListMock } from './worktrees-slice-test-harness' const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) @@ -96,7 +96,7 @@ describe('fetchWorktrees', () => { branch: 'refs/heads/feature' }) const refreshed = { ...initial, linkedGitLabIssue: 321 } - mockApi.worktrees.list.mockResolvedValue([refreshed]) + worktreeListMock.mockResolvedValue([refreshed]) store.setState({ worktreesByRepo: { repo1: [initial] }, sortEpoch: 7 diff --git a/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts b/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts index 77a36bfcb74..9e8bd30485b 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts @@ -13,7 +13,8 @@ import { createTestStore, mockApi, resetRemoteRuntimeMocks, - resetWorktreeSliceModuleMemory + resetWorktreeSliceModuleMemory, + worktreeListMock } from './worktrees-slice-test-harness' const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) @@ -54,7 +55,7 @@ describe('fetchWorktrees', () => { path: '/path/surviving' }) - mockApi.worktrees.list.mockResolvedValue([surviving]) + worktreeListMock.mockResolvedValue([surviving]) store.setState({ worktreesByRepo: { repo1: [removed, surviving] }, sortEpoch: 7, @@ -91,7 +92,7 @@ describe('fetchWorktrees', () => { path: '/path/surviving' }) - mockApi.worktrees.list.mockResolvedValue([surviving]) + worktreeListMock.mockResolvedValue([surviving]) store.setState({ worktreesByRepo: { repo1: [removed, surviving] }, sortEpoch: 7, @@ -135,7 +136,7 @@ describe('fetchWorktrees', () => { path: '/path/surviving' }) - mockApi.worktrees.list.mockResolvedValue([surviving]) + worktreeListMock.mockResolvedValue([surviving]) store.setState({ worktreesByRepo: { repo1: [removed, surviving] } } as Partial) await store.getState().updateWorktreeMeta(removed.id, { linkedBitbucketPR: 101 }) await store.getState().updateWorktreeMeta(surviving.id, { linkedAzureDevOpsPR: 202 }) @@ -469,7 +470,7 @@ describe('fetchWorktrees', () => { it('accepts an empty refresh when the repo had no cached worktrees', async () => { const store = createTestStore() - mockApi.worktrees.list.mockResolvedValue([]) + worktreeListMock.mockResolvedValue([]) store.setState({ worktreesByRepo: {}, sortEpoch: 7 } as Partial) await store.getState().fetchWorktrees('repo1') diff --git a/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts b/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts index 9684792ba6e..0ff8fbab2b9 100644 --- a/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts +++ b/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts @@ -56,7 +56,6 @@ describe('updateWorktreeGitIdentity', () => { branch: 'refs/heads/feature' }) expect(store.getState().sortEpoch).toBe(4) - expect(mockApi.worktrees.list).not.toHaveBeenCalled() expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts b/src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts index 63d0f9fecf2..438a814da6c 100644 --- a/src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts @@ -6,7 +6,8 @@ import { createTestStore, mockApi, resetRemoteRuntimeMocks, - resetWorktreeSliceModuleMemory + resetWorktreeSliceModuleMemory, + worktreeListMock } from './worktrees-slice-test-harness' const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) @@ -370,7 +371,7 @@ describe('updateWorktreeGitIdentity', () => { }) }) - mockApi.worktrees.list.mockResolvedValue([staleRefetch]) + worktreeListMock.mockResolvedValue([staleRefetch]) await store.getState().fetchWorktrees('repo1') expect(store.getState().worktreesByRepo.repo1[0]?.linkedItems).toContainEqual({ @@ -417,7 +418,7 @@ describe('updateWorktreeGitIdentity', () => { store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', { branch: 'refs/heads/stack/one' }) - mockApi.worktrees.list.mockResolvedValue([laterBranch]) + worktreeListMock.mockResolvedValue([laterBranch]) await store.getState().fetchWorktrees('repo1') expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ @@ -465,9 +466,9 @@ describe('updateWorktreeGitIdentity', () => { }) }) - mockApi.worktrees.list.mockResolvedValue([cleanHeadAdvance]) + worktreeListMock.mockResolvedValue([cleanHeadAdvance]) await store.getState().fetchWorktrees('repo1') - mockApi.worktrees.list.mockResolvedValue([staleLinkedRow]) + worktreeListMock.mockResolvedValue([staleLinkedRow]) await store.getState().fetchWorktrees('repo1') expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ @@ -506,7 +507,7 @@ describe('updateWorktreeGitIdentity', () => { }) }) - mockApi.worktrees.list.mockResolvedValue([laterBranch]) + worktreeListMock.mockResolvedValue([laterBranch]) await store.getState().fetchWorktrees('repo1') expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ @@ -515,7 +516,7 @@ describe('updateWorktreeGitIdentity', () => { pushTarget: undefined }) - mockApi.worktrees.list.mockResolvedValue([ + worktreeListMock.mockResolvedValue([ { ...existing, linkedPR: 101, diff --git a/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts b/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts index 9e90ae0f379..b5fef2ccb32 100644 --- a/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts +++ b/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts @@ -13,7 +13,8 @@ import { resetRemoteRuntimeMocks, resetWorktreeSliceModuleMemory, runtimeEnvironmentCall, - runtimeEnvironmentTransportCall + runtimeEnvironmentTransportCall, + worktreeListMock } from './worktrees-slice-test-harness' const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) @@ -177,7 +178,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' }) // repoA succeeds, repoB throws; a stale tabsByWorktree entry must NOT be purged while any repo fetch is degraded. - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => { + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => { if (repoId === 'repoA') { return [wtA] } @@ -202,7 +203,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { }) // After repoB recovers, the deferred purge fires for genuinely stale ids. - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => { + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => { if (repoId === 'repoA') { return [wtA] } @@ -221,7 +222,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { const store = createTestStore() // Empty valid-id union (both repos newly-cloned, empty) isn't authoritative — defer instead of wiping tabsByWorktree. - mockApi.worktrees.list.mockResolvedValue([]) + worktreeListMock.mockResolvedValue([]) store.setState({ repos: [repoA, repoB], @@ -245,7 +246,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { const folderWorkspace = makeFolderWorkspace({ id: 'folder-keep' }) const folderKey = folderWorkspaceKey(folderWorkspace.id) - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => repoId === 'repoA' ? [wtA] : [wtB] ) @@ -269,7 +270,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() expect(store.getState().hasHydratedWorktreePurge).toBe(true) - expect(mockApi.worktrees.list).toHaveBeenCalledTimes(2) + expect(worktreeListMock).toHaveBeenCalledTimes(2) expect(store.getState().tabsByWorktree).toEqual({ 'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }], 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }], @@ -291,7 +292,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() - expect(mockApi.worktrees.list).toHaveBeenCalledTimes(4) + expect(worktreeListMock).toHaveBeenCalledTimes(4) expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined() }) @@ -300,7 +301,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' }) - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => repoId === 'repoA' ? [wtA] : [wtB] ) @@ -332,7 +333,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' }) - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => repoId === 'repoA' ? [wtA] : [wtB] ) @@ -374,7 +375,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) // repoB reports zero worktrees this round (host briefly empty), repoA fine. - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => repoId === 'repoA' ? [wtA] : [] ) @@ -517,7 +518,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { let activeScans = 0 let maxActiveScans = 0 - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => { + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => { activeScans += 1 maxActiveScans = Math.max(maxActiveScans, activeScans) await new Promise((resolve) => setTimeout(resolve, 5)) @@ -530,7 +531,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) - expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) + expect(worktreeListMock).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) @@ -546,7 +547,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { let activeScans = 0 let maxActiveScans = 0 - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => { + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => { activeScans += 1 maxActiveScans = Math.max(maxActiveScans, activeScans) await new Promise((resolve) => setTimeout(resolve, 5)) @@ -562,7 +563,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) - expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) + expect(worktreeListMock).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) @@ -676,7 +677,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { mode: 'edit' as const } - mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + worktreeListMock.mockImplementation(async ({ repoId }: { repoId: string }) => repoId === 'repoA' ? [wtA] : [wtB] ) diff --git a/src/renderer/src/store/slices/worktrees-identity-migration.test.ts b/src/renderer/src/store/slices/worktrees-identity-migration.test.ts index 7d709f33ac7..40e199bac68 100644 --- a/src/renderer/src/store/slices/worktrees-identity-migration.test.ts +++ b/src/renderer/src/store/slices/worktrees-identity-migration.test.ts @@ -17,7 +17,8 @@ import { createTestStore, mockApi, resetRemoteRuntimeMocks, - resetWorktreeSliceModuleMemory + resetWorktreeSliceModuleMemory, + worktreeListMock } from './worktrees-slice-test-harness' const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) @@ -269,7 +270,7 @@ describe('migrateWorktreeIdentity', () => { const switched = store.getState().worktreesByRepo.repo1[0] store.setState({ worktreesByRepo: { repo1: [{ ...switched, id: NEW }] } } as Partial) store.getState().migrateWorktreeIdentity(OLD, NEW) - mockApi.worktrees.list.mockResolvedValue([ + worktreeListMock.mockResolvedValue([ { ...switched, id: NEW, @@ -316,7 +317,7 @@ describe('migrateWorktreeIdentity', () => { const switched = store.getState().worktreesByRepo.repo1[0] store.setState({ worktreesByRepo: { repo1: [{ ...switched, id: NEW }] } } as Partial) store.getState().migrateWorktreeIdentity(OLD, NEW) - mockApi.worktrees.list.mockResolvedValue([ + worktreeListMock.mockResolvedValue([ { ...oldWorktree, head: 'old-head', diff --git a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts index f11e019b664..cff49aff22c 100644 --- a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts +++ b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts @@ -71,7 +71,6 @@ export const mockApi = { create: stubMock(), adoptProvisionedRoot: stubMock(), prefetchCreateBase: stubMock().mockResolvedValue(undefined), - list: worktreeListMock, listDetected: listDetectedMock, listKnownForExecutionHost: listKnownForExecutionHostMock, forgetRemovedForExecutionHost: forgetRemovedForExecutionHostMock, diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts index bc3a9328d35..6c885516eca 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts @@ -113,23 +113,7 @@ export function rejectedDetectedWorktreeProviderResult( export async function startDetectedWorktreeProviderRequest( request: ListDetectedWorktreesArgs ): Promise { - const worktreesApi = window.api.worktrees as typeof window.api.worktrees & { - listDetected?: typeof window.api.worktrees.listDetected - } - if (typeof worktreesApi.listDetected !== 'function') { - if (request.executionHostId !== LOCAL_EXECUTION_HOST_ID) { - return rejectedDetectedWorktreeProviderResult(request) - } - const worktrees = await worktreesApi.list({ repoId: request.repoId }) - return { - status: 'complete', - providerRequestId: request.providerRequestId, - repoId: request.repoId, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - result: toLegacyDetectedWorktreeResult(request.repoId, { worktrees }) - } - } - const result = await worktreesApi.listDetected(request) + const result = await window.api.worktrees.listDetected(request) if (result && typeof result === 'object' && 'status' in result && 'providerRequestId' in result) { return result as unknown as HostQualifiedDetectedWorktreeResult } diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index 64a9f8ec0be..153254400cf 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -27,7 +27,6 @@ export const webRuntimeState: { worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null activeClient: WebRuntimeClient | null activeClientEnvironmentId: string | null - cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null } = { activeEnvironment: readStoredWebRuntimeEnvironment(), @@ -37,7 +36,6 @@ export const webRuntimeState: { worktreeVisibilityDefaultsRuntimeValue: null, activeClient: null, activeClientEnvironmentId: null, - cachedWorktrees: null, cachedDetectedWorktrees: null } @@ -84,7 +82,6 @@ export const manuallyDisconnectedEnvironmentIds = new Set() export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() export function invalidateRuntimeWorktreeCaches(): void { - webRuntimeState.cachedWorktrees = null webRuntimeState.cachedDetectedWorktrees = null } diff --git a/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts index 1cc6de1273e..600a816e62f 100644 --- a/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts +++ b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts @@ -5,7 +5,6 @@ import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/wo import { callRuntimeEnvelope, callRuntimeResult, - callRuntimeResultWithOwner, withRuntimeWorktreeOwner } from './web-runtime-calls' import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' @@ -15,25 +14,7 @@ import { webRuntimeState } from './web-runtime-session' -export const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 - -export async function listAllRuntimeWorktrees(): Promise { - if ( - webRuntimeState.cachedWorktrees && - Date.now() - webRuntimeState.cachedWorktrees.loadedAt < 5_000 - ) { - return webRuntimeState.cachedWorktrees.worktrees - } - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - const worktrees = owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - assertActiveEnvironment(owned.environmentId) - webRuntimeState.cachedWorktrees = { loadedAt: Date.now(), worktrees } - return worktrees -} +const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 export async function listAllRuntimeDetectedWorktrees( callResult: WebRuntimeResultCaller = callRuntimeResult, diff --git a/src/renderer/src/web/preload-api/web-worktree-attachment-mutations.test.ts b/src/renderer/src/web/preload-api/web-worktree-attachment-mutations.test.ts index d8d8ec140bd..206a4274073 100644 --- a/src/renderer/src/web/preload-api/web-worktree-attachment-mutations.test.ts +++ b/src/renderer/src/web/preload-api/web-worktree-attachment-mutations.test.ts @@ -18,11 +18,7 @@ vi.mock('./web-runtime-session', () => ({ requireActiveEnvironment: () => ({ id: 'env' }), assertActiveEnvironment: mocks.assertEnvironment })) -vi.mock('./web-runtime-worktree-catalog', () => ({ - WEB_RUNTIME_WORKTREE_LIST_LIMIT: 500, - callRuntimeDetectedWorktrees: vi.fn(), - listAllRuntimeWorktrees: vi.fn() -})) +vi.mock('./web-runtime-worktree-catalog', () => ({ callRuntimeDetectedWorktrees: vi.fn() })) beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index 3d242df0f50..d661b66b30f 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -27,11 +27,7 @@ import { requireActiveEnvironment, assertActiveEnvironment } from './web-runtime-session' -import { - WEB_RUNTIME_WORKTREE_LIST_LIMIT, - callRuntimeDetectedWorktrees, - listAllRuntimeWorktrees -} from './web-runtime-worktree-catalog' +import { callRuntimeDetectedWorktrees } from './web-runtime-worktree-catalog' import { noopUnsubscribe } from './web-storage' import { emitWebWorkspaceSleep } from './web-workspace-sleep-notices' import { translate } from '@/i18n/i18n' @@ -42,15 +38,6 @@ const WEB_WORKSPACE_SLEEP_TIMEOUT_MS = 150_000 export function createWorktreesApi(): NonNullable['worktrees']> { return { - list: async ({ repoId }) => { - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - repo: repoId, - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - return owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - }, // Why the catch: a host predating this method answers `method_not_found`, and an empty list // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. listRetiredNames: async ({ repoId }) => { @@ -64,7 +51,6 @@ export function createWorktreesApi(): NonNullable['worktrees } }, listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), - listAll: () => listAllRuntimeWorktrees(), create: async (args) => { if (args.linkedItems !== undefined) { await assertAttachmentWriteSupported(args) diff --git a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts index f2ecece107d..306d975a6e1 100644 --- a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts +++ b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts @@ -241,25 +241,23 @@ describe('web worktree preload API', () => { }) it.each(['web-server-a', 'web-server-b'])( - 'attributes server-local worktrees to their own paired runtime %s', + 'attributes server-local detected worktrees to their own paired runtime %s', async (environmentId) => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { call(): Promise> { return Promise.resolve({ - id: 'worktree-list', + id: 'detected-list', ok: true, result: { + repoId: 'repo-1', + authoritative: true, + source: 'git', worktrees: [ + { id: 'repo-1::/srv/repo', repoId: 'repo-1', path: '/srv/repo', hostId: 'local' }, { - id: 'repo-1::/srv/repo', + id: 'repo-1::/ssh/repo', repoId: 'repo-1', - path: '/srv/repo', - hostId: 'local' - }, - { - id: 'repo-2::/ssh/repo', - repoId: 'repo-2', path: '/ssh/repo', hostId: 'ssh:hub-private-target' } @@ -272,45 +270,57 @@ describe('web worktree preload API', () => { close(): void {} } })) - const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage, environmentId) const { installWebPreloadApi } = await import('./web-preload-api') installWebPreloadApi() - await expect(globals.window.api.worktrees.list({ repoId: 'repo-1' })).resolves.toMatchObject([ - { - id: 'repo-1::/srv/repo', - hostId: 'local', - runtimeOwnerEnvironmentId: environmentId - }, - { - id: 'repo-2::/ssh/repo', - hostId: 'ssh:hub-private-target', - runtimeOwnerEnvironmentId: environmentId - } - ]) + await expect( + globals.window.api.worktrees.listDetected({ repoId: 'repo-1' }) + ).resolves.toMatchObject({ + repoId: 'repo-1', + authoritative: true, + worktrees: [ + { hostId: 'local', runtimeOwnerEnvironmentId: environmentId }, + { hostId: 'ssh:hub-private-target', runtimeOwnerEnvironmentId: environmentId } + ] + }) } ) - it('does not let a stale listAll response repopulate the next server cache', async () => { + it('does not let a stale detected-worktree catalog repopulate the next server cache', async () => { let resolveServerA: ((response: RuntimeRpcResponse) => void) | undefined + const detectedList = (repoId: string, path: string): RuntimeRpcResponse => ({ + id: `detected-${repoId}`, + ok: true, + result: { + repoId, + authoritative: true, + source: 'git', + worktrees: [{ id: `${repoId}::${path}`, repoId, path }] + }, + _meta: { runtimeId: `runtime-${repoId}` } + }) vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { constructor(private readonly connection: { hostPublicKeyB64: string }) {} - call(): Promise> { - if (this.connection.hostPublicKeyB64 === 'public-key') { + call(method: string): Promise> { + const serverA = this.connection.hostPublicKeyB64 === 'public-key' + if (method === 'repo.list') { + return Promise.resolve({ + id: 'repo-list', + ok: true, + result: { repos: [{ id: serverA ? 'repo-a' : 'repo-b' }] }, + _meta: { runtimeId: serverA ? 'runtime-a' : 'runtime-b' } + }) + } + if (serverA) { return new Promise((resolve) => { resolveServerA = resolve }) } - return Promise.resolve({ - id: 'server-b-list', - ok: true, - result: { worktrees: [{ id: 'worktree-b', repoId: 'repo-b', path: '/srv/b' }] }, - _meta: { runtimeId: 'runtime-b' } - }) + return Promise.resolve(detectedList('repo-b', '/srv/b')) } close(): void {} @@ -322,40 +332,41 @@ describe('web worktree preload API', () => { const { installWebPreloadApi } = await import('./web-preload-api') installWebPreloadApi() - const serverAList = globals.window.api.worktrees.listAll() + const serverAStatus = globals.window.api.git.status({ worktreePath: '/srv/a' }) await vi.waitFor(() => expect(resolveServerA).toBeTypeOf('function')) - const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + await globals.window.api.runtimeEnvironments.addFromPairingCode({ name: 'Server B', pairingCode: encodePairingCode({ publicKeyB64: 'server-b-key' }) }) - resolveServerA?.({ - id: 'server-a-list', - ok: true, - result: { worktrees: [{ id: 'worktree-a', repoId: 'repo-a', path: '/srv/a' }] }, - _meta: { runtimeId: 'runtime-a' } - }) + resolveServerA?.(detectedList('repo-a', '/srv/a')) - await expect(serverAList).rejects.toThrow( + await expect(serverAStatus).rejects.toThrow( 'The paired Orca server changed while the request was in progress.' ) - await expect(globals.window.api.worktrees.listAll()).resolves.toMatchObject([ - { id: 'worktree-b', runtimeOwnerEnvironmentId: paired.environment.id } - ]) + await expect(globals.window.api.shell.pathExists('/srv/a/file.ts')).resolves.toBe(false) + await expect(globals.window.api.shell.pathExists('/srv/b/file.ts')).resolves.toBe(true) }) - it('reuses the worktree catalog cache until disconnect invalidates its runtime owner', async () => { + it('reuses the detected-worktree catalog cache until disconnect invalidates its runtime owner', async () => { const calls: string[] = [] vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { call(method: string): Promise> { calls.push(method) + const results: Record = { + 'status.get': { runtimeId: 'runtime-a', version: '1.0.0' }, + 'repo.list': { repos: [{ id: 'repo-a' }] }, + 'worktree.detectedList': { + repoId: 'repo-a', + authoritative: true, + source: 'git', + worktrees: [{ id: 'repo-a::/srv/a', repoId: 'repo-a', path: '/srv/a' }] + } + } return Promise.resolve({ id: method, ok: true, - result: - method === 'status.get' - ? { runtimeId: 'runtime-a', version: '1.0.0' } - : { worktrees: [{ id: 'worktree-a', repoId: 'repo-a', path: '/srv/a' }] }, + result: results[method], _meta: { runtimeId: 'runtime-a' } }) } @@ -368,64 +379,19 @@ describe('web worktree preload API', () => { const { installWebPreloadApi } = await import('./web-preload-api') installWebPreloadApi() - await globals.window.api.worktrees.listAll() - await globals.window.api.worktrees.listAll() + await expect(globals.window.api.shell.pathExists('/srv/a/one.ts')).resolves.toBe(true) + await expect(globals.window.api.shell.pathExists('/srv/a/two.ts')).resolves.toBe(true) await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' }) await globals.window.api.runtimeEnvironments.connect({ selector: 'web-server-a' }) - await globals.window.api.worktrees.listAll() + await expect(globals.window.api.shell.pathExists('/srv/a/one.ts')).resolves.toBe(true) - expect(calls).toEqual(['worktree.list', 'status.get', 'worktree.list']) - }) - - it('preserves runtime-routed detected-worktree host ownership in the compatibility shape', async () => { - vi.doMock('./web-runtime-client', () => ({ - WebRuntimeClient: class { - call(): Promise> { - return Promise.resolve({ - id: 'detected-list', - ok: true, - result: { - repoId: 'repo-1', - authoritative: true, - source: 'git', - worktrees: [ - { id: 'repo-1::/srv/repo', repoId: 'repo-1', path: '/srv/repo', hostId: 'local' }, - { - id: 'repo-1::/ssh/repo', - repoId: 'repo-1', - path: '/ssh/repo', - hostId: 'ssh:hub-private-target' - } - ] - }, - _meta: { runtimeId: 'runtime-1' } - }) - } - - close(): void {} - } - })) - const globals = installBrowserGlobals('Linux') - writeStoredRuntimeEnvironment(globals.storage, 'web-env-1') - const { installWebPreloadApi } = await import('./web-preload-api') - installWebPreloadApi() - - await expect( - globals.window.api.worktrees.listDetected({ repoId: 'repo-1' }) - ).resolves.toMatchObject({ - repoId: 'repo-1', - authoritative: true, - worktrees: [ - { - hostId: 'local', - runtimeOwnerEnvironmentId: 'web-env-1' - }, - { - hostId: 'ssh:hub-private-target', - runtimeOwnerEnvironmentId: 'web-env-1' - } - ] - }) + expect(calls).toEqual([ + 'repo.list', + 'worktree.detectedList', + 'status.get', + 'repo.list', + 'worktree.detectedList' + ]) }) it('falls back to legacy worktree.list when detectedList is unavailable', async () => { diff --git a/tests/e2e/helpers/repo-with-workspaces.ts b/tests/e2e/helpers/repo-with-workspaces.ts index 528b8a8dc2c..a2de35c53b8 100644 --- a/tests/e2e/helpers/repo-with-workspaces.ts +++ b/tests/e2e/helpers/repo-with-workspaces.ts @@ -110,8 +110,8 @@ export async function writeWorkspaceMeta( await window.__store!.getState().updateWorktreesMeta(updates) const repoIds = [...new Set(updates.map(({ worktreeId }) => worktreeId.split('::')[0]!))] const listed = new Map( - (await Promise.all(repoIds.map((repoId) => window.api.worktrees.list({ repoId })))) - .flat() + (await Promise.all(repoIds.map((repoId) => window.api.worktrees.listDetected({ repoId })))) + .flatMap((result) => result.worktrees.filter((worktree) => worktree.visible)) .map((worktree) => [worktree.id, worktree]) ) for (const { worktreeId, updates: written } of updates) { diff --git a/tests/e2e/helpers/worktree-registration.ts b/tests/e2e/helpers/worktree-registration.ts index f4126aad89a..eb5b75d6fb2 100644 --- a/tests/e2e/helpers/worktree-registration.ts +++ b/tests/e2e/helpers/worktree-registration.ts @@ -4,7 +4,7 @@ import type { CommitMessageAiSettings } from '../../../src/shared/commit-message // Why: these specs create worktrees via raw `git worktree add`, which bypasses // Orca's own add/remove path — the one thing that invalidates the main-process -// worktrees.list scan cache (DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000). So a +// worktrees.listDetected scan cache (DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000). So a // read inside the TTL window can serve a stale miss. No renderer-reachable // cache-invalidation seam exists without adding product surface, so poll past // the deterministic 5s boundary. Budget 10s (not 6s, which sits dangerously diff --git a/tests/e2e/manual-worktree-order-persistence.spec.ts b/tests/e2e/manual-worktree-order-persistence.spec.ts index 355d3c97817..99b6be165c5 100644 --- a/tests/e2e/manual-worktree-order-persistence.spec.ts +++ b/tests/e2e/manual-worktree-order-persistence.spec.ts @@ -155,10 +155,15 @@ test('manual drag survives activity and persisted-profile reload', async ({ await expect .poll(async () => { const [ui, worktrees] = await first.page.evaluate(async (ids) => { - const [persistedUi, persistedWorktrees] = await Promise.all([ + const [persistedUi, repos] = await Promise.all([ window.api.ui.get(), - window.api.worktrees.listAll() + window.api.repos.list() ]) + const persistedWorktrees = ( + await Promise.all( + repos.map((repo) => window.api.worktrees.listDetected({ repoId: repo.id })) + ) + ).flatMap((result) => result.worktrees.filter((worktree) => worktree.visible)) return [ persistedUi, persistedWorktrees diff --git a/tests/e2e/worktree-recent-sort.spec.ts b/tests/e2e/worktree-recent-sort.spec.ts index cd92fbf1f25..f8dafa980a9 100644 --- a/tests/e2e/worktree-recent-sort.spec.ts +++ b/tests/e2e/worktree-recent-sort.spec.ts @@ -9,7 +9,7 @@ * `lastActivityAt` descending, so those worktrees landed dead last — * even though the user had just added them. * - * The `worktrees:list` / `worktrees:listAll` handlers now stamp + * The `worktrees:listDetected` handler now stamps * `lastActivityAt = Date.now()` on first discovery. This test locks that * behavior in end-to-end. */ diff --git a/tests/e2e/wsl-move-fence.spec.ts b/tests/e2e/wsl-move-fence.spec.ts index 53b9f4dcfdd..150eed0ca29 100644 --- a/tests/e2e/wsl-move-fence.spec.ts +++ b/tests/e2e/wsl-move-fence.spec.ts @@ -105,11 +105,20 @@ async function edit( } async function listed(page: Page) { - return page.evaluate(async () => ({ - folders: (await window.api.folderWorkspaces.list()).map((workspace) => workspace.id), - repos: (await window.api.repos.list()).map((repo) => repo.id), - worktrees: (await window.api.worktrees.listAll()).map((worktree) => worktree.repoId) - })) + return page.evaluate(async () => { + // Why repos first: the repo catalog is the moved-project fence; worktrees are listed per catalog repo. + const repos = (await window.api.repos.list()).map((repo) => repo.id) + const listings = await Promise.all( + repos.map((repoId) => window.api.worktrees.listDetected({ repoId })) + ) + return { + folders: (await window.api.folderWorkspaces.list()).map((workspace) => workspace.id), + repos, + worktrees: listings.flatMap((result) => + result.worktrees.filter((worktree) => worktree.visible).map((worktree) => worktree.repoId) + ) + } + }) } test('a WSL move journal fences the moving project, then hides it once the host owns it', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. @@ -195,7 +204,6 @@ test('a WSL move journal fences the moving project, then hides it once the host expect(owned.folders).toContain(stayingId) expect(owned.folders).not.toContain(movingId) expect(owned.repos).not.toContain(repoId) - expect(owned.worktrees).not.toContain(repoId) } finally { if (launched) { await session.close(launched.app)