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>
This commit is contained in:
OrcaWin
2026-10-23 17:30:59 -07:00
committed by GitHub
co-authored by OrcaWin
parent 96ac40e244
commit d1aef1eee9
56 changed files with 423 additions and 863 deletions
@@ -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: [] })
@@ -155,19 +155,10 @@ function scanExpectation(
}
}
function listDetected(): Promise<unknown> {
return handlers['worktrees:listDetected'](null, { repoId: REPO_ID }) as Promise<unknown>
async function listDetected(): Promise<unknown> {
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<unknown>
],
['worktrees:listAll', () => handlers['worktrees:listAll'](null, undefined) as Promise<unknown>]
] 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()
})
@@ -100,8 +100,11 @@ function remove(args: Record<string, unknown>): Promise<RemoveWorktreeResult> {
type ListedRow = { id: string; removing?: true }
async function listRepoRows(): Promise<ListedRow[]> {
// 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(): {
@@ -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<Repo, 'connectionId' | 'executionHostId'>) {
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()
@@ -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',
@@ -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<void> => {
while (listWorktreesMock.mock.calls.length < count) {
await new Promise<void>((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<void>((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))
})
})
@@ -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<string, unknown>): Promise<RemoveWorktreeResult> {
return handlers['worktrees:remove'](null, args) as Promise<RemoveWorktreeResult>
}
async function listFeature(): Promise<Worktree | undefined> {
// 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<DetectedWorktree | undefined> {
// 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. */
@@ -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) })
)
})
@@ -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<DetectedWorktree[]> {
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',
+19 -1
View File
@@ -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
}
+5 -5
View File
@@ -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
}
})
])
})
-2
View File
@@ -30,8 +30,6 @@ registerDetectedWorktreeScanInvalidation()
let disposeSparseCheckoutCacheInvalidation: (() => void) | undefined
const WORKTREE_HANDLER_CHANNELS = [
'worktrees:listAll',
'worktrees:list',
'worktrees:listRetiredNames',
'worktrees:listDetected',
'worktrees:listKnownForExecutionHost',
@@ -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,
@@ -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<T extends Worktree>(
worktrees: T[],
pendingAtScan: PendingWorktreeRemovals
): T[] {
return projectPendingWorktreeRemovals(worktrees, (worktree) => worktree.id, true, pendingAtScan)
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
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<ExecutionHostId, Record<string, WorktreeMeta>>()
const metadataForRepo = (repo: (typeof repos)[number]): Record<string, WorktreeMeta> => {
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<typeof createSshWorktreeMetaIndex>
>()
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 []
}
})
}
@@ -203,13 +203,3 @@ export function buildDetectedGitWorktrees(
})
return projectWorktreeLineageWithHostPeers(store, repo, detected)
}
export function stampAndMergeVisibleDetectedWorktree(
store: Store,
repo: Repo,
detected: DetectedWorktree,
allMetaOverride?: Record<string, WorktreeMeta>
) {
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id, allMetaOverride)
return mergeWorktree(repo.id, detected, meta, repo.displayName)
}
@@ -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,
@@ -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'
-2
View File
@@ -43,7 +43,6 @@ import type {
} from '../../shared/worktree/types'
export type WorktreeApi = {
list: (args: { repoId: string }) => Promise<Worktree[]>
/** Retired names prevent path reuse; fully spent tiers are compacted into a watermark. */
listRetiredNames: (args: { repoId: string }) => Promise<RetiredNameRegistry>
listDetected: {
@@ -60,7 +59,6 @@ export type WorktreeApi = {
args: ForgetRemovedWorktreesForExecutionHostArgs
) => Promise<ForgetRemovedWorktreesForExecutionHostResult>
cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise<void>
listAll: () => Promise<Worktree[]>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise<CreateWorktreeResult>
/** Local creates report progress by creationId; remote/runtime creates emit nothing. */
-3
View File
@@ -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),
@@ -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({})
@@ -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({})
@@ -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({})
@@ -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) }
}
@@ -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
@@ -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({})
@@ -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({})
@@ -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({})
@@ -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({})
},
@@ -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(
@@ -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 }),
@@ -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({})
@@ -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({})
@@ -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({})
@@ -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({})
@@ -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() },
@@ -162,7 +162,7 @@ export type WorktreeSlice = {
lastVisitedAtByWorktreeId: Record<string, number>
/**
* 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
@@ -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({})
@@ -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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
await store.getState().fetchWorktrees('repo1')
@@ -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
@@ -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<AppState>)
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<AppState>)
await store.getState().fetchWorktrees('repo1')
@@ -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()
})
@@ -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,
@@ -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]
)
@@ -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<AppState>)
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<AppState>)
store.getState().migrateWorktreeIdentity(OLD, NEW)
mockApi.worktrees.list.mockResolvedValue([
worktreeListMock.mockResolvedValue([
{
...oldWorktree,
head: 'old-head',
@@ -71,7 +71,6 @@ export const mockApi = {
create: stubMock(),
adoptProvisionedRoot: stubMock(),
prefetchCreateBase: stubMock().mockResolvedValue(undefined),
list: worktreeListMock,
listDetected: listDetectedMock,
listKnownForExecutionHost: listKnownForExecutionHostMock,
forgetRemovedForExecutionHost: forgetRemovedForExecutionHostMock,
@@ -113,23 +113,7 @@ export function rejectedDetectedWorktreeProviderResult(
export async function startDetectedWorktreeProviderRequest(
request: ListDetectedWorktreesArgs
): Promise<HostQualifiedDetectedWorktreeResult> {
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
}
@@ -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<string>()
export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
export function invalidateRuntimeWorktreeCaches(): void {
webRuntimeState.cachedWorktrees = null
webRuntimeState.cachedDetectedWorktrees = null
}
@@ -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<Worktree[]> {
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,
@@ -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()
@@ -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<Partial<PreloadApi>['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<Partial<PreloadApi>['worktrees
}
},
listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId),
listAll: () => listAllRuntimeWorktrees(),
create: async (args) => {
if (args.linkedItems !== undefined) {
await assertAttachmentWriteSupported(args)
@@ -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<RuntimeRpcResponse<unknown>> {
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<unknown>) => void) | undefined
const detectedList = (repoId: string, path: string): RuntimeRpcResponse<unknown> => ({
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<RuntimeRpcResponse<unknown>> {
if (this.connection.hostPublicKeyB64 === 'public-key') {
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
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<RuntimeRpcResponse<unknown>> {
calls.push(method)
const results: Record<string, unknown> = {
'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<RuntimeRpcResponse<unknown>> {
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 () => {
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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.
*/
+14 -6
View File
@@ -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)