mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
+8
-7
@@ -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,
|
||||
|
||||
+1
-17
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user