Display SSH worktrees immediately using persisted metadata (#12646)
* Display SSH worktrees immediately using persisted metadata Users can now see known worktrees for SSH hosts without waiting for the provider connection to establish. Worktrees are fetched from local metadata and displayed as non-authoritative, then merged without replacing richer live data once the provider becomes available. * Show SSH folder workspaces immediately via persisted metadata Add safeguards for metadata fallback: track authoritatively removed worktrees per host to prevent resurrection, position new rows within the host block to avoid jumping on authoritative scan arrival, and preserve co-owner detection status during merge. Coalesce concurrent metadata fetches to dedupe overlapping queries.
This commit is contained in:
parent
ae1ed5e886
commit
2ff2a1b268
|
|
@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
|
|||
import { join, resolve } from 'node:path'
|
||||
import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types'
|
||||
import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import * as localWorktreeFilesystem from '../local-worktree-filesystem'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
|
|
@ -2589,6 +2589,139 @@ describe('registerWorktreeHandlers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('lists every persisted SSH worktree without accessing the live provider', async () => {
|
||||
const sshHostId = toSshExecutionHostId('target-a')
|
||||
const sshRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'target-a'
|
||||
}
|
||||
const metaById = {
|
||||
'repo-1::/remote/repo': makeWorktreeMeta({
|
||||
displayName: 'main',
|
||||
hostId: sshHostId
|
||||
}),
|
||||
'repo-1::/remote/queued': makeWorktreeMeta({
|
||||
displayName: 'queued',
|
||||
hostId: sshHostId
|
||||
}),
|
||||
'repo-1::/remote/other-host': makeWorktreeMeta({
|
||||
displayName: 'other host',
|
||||
hostId: toSshExecutionHostId('target-b')
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([sshRepo])
|
||||
store.getProjectHostSetups.mockReturnValue([])
|
||||
store.getAllWorktreeMeta.mockReturnValue(metaById)
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
|
||||
|
||||
const result = await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId,
|
||||
result: {
|
||||
repoId: sshRepo.id,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: [
|
||||
expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }),
|
||||
expect.objectContaining({ path: '/remote/queued', isMainWorktree: false })
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists SSH folder workspaces at the folder path, not the instance-suffixed id', async () => {
|
||||
const sshHostId = toSshExecutionHostId('target-a')
|
||||
const folderRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/remote/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'target-a',
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const rootId = `${folderRepo.id}::${folderRepo.path}`
|
||||
const instanceId = `${rootId}::workspace:11111111-2222-3333-4444-555555555555`
|
||||
const metaById = {
|
||||
[rootId]: makeWorktreeMeta({ displayName: 'root', hostId: sshHostId }),
|
||||
[instanceId]: makeWorktreeMeta({
|
||||
displayName: 'second workspace',
|
||||
hostId: sshHostId,
|
||||
instanceId: '11111111-2222-3333-4444-555555555555'
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([folderRepo])
|
||||
store.getProjectHostSetups.mockReturnValue([])
|
||||
store.getAllWorktreeMeta.mockReturnValue(metaById)
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
|
||||
|
||||
const result = (await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: folderRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})) as { status: string; result: { authoritative: boolean; worktrees: Worktree[] } }
|
||||
|
||||
expect(result.status).toBe('complete')
|
||||
expect(result.result.authoritative).toBe(false)
|
||||
// Why: the git-worktree synthesizer would read the "::workspace:<uuid>" tail as a directory.
|
||||
expect(result.result.worktrees).toEqual([
|
||||
expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }),
|
||||
expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false })
|
||||
])
|
||||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects metadata-only reads for non-SSH execution hosts', async () => {
|
||||
const result = await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: 'repo-1',
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
repoId: 'repo-1',
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
expect(store.getRepos).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects ambiguous metadata-only SSH owners', async () => {
|
||||
const sshHostId = toSshExecutionHostId('target-a')
|
||||
const sshRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/remote/repo-a',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'target-a'
|
||||
}
|
||||
store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }])
|
||||
|
||||
const result = await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})
|
||||
expect(store.getAllWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => {
|
||||
const sshRepo = {
|
||||
id: 'shared-repo',
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ import {
|
|||
import {
|
||||
PROVIDER_REQUEST_ID_MAX_UTF8_BYTES,
|
||||
type DirectSshDetectedWorktreeRequest,
|
||||
type HostQualifiedKnownWorktreeResult,
|
||||
type HostQualifiedDetectedWorktreeResult,
|
||||
type ListKnownWorktreesForExecutionHostArgs,
|
||||
type ListDetectedWorktreesArgs,
|
||||
type ProviderRequestId
|
||||
} from '../../shared/detected-worktree-provider-contract'
|
||||
|
|
@ -229,7 +231,8 @@ import {
|
|||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import {
|
||||
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
|
||||
getRepoIdFromWorktreeId
|
||||
getRepoIdFromWorktreeId,
|
||||
getWorktreePathBasenameFromId
|
||||
} from '../../shared/worktree-id'
|
||||
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
|
||||
import {
|
||||
|
|
@ -804,6 +807,17 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt
|
|||
return index
|
||||
}
|
||||
|
||||
// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole
|
||||
// store, so this is cheaper per call than the unfiltered index, not free.
|
||||
function createSshWorktreeMetaIndexForRepo(
|
||||
allMeta: Record<string, WorktreeMeta>,
|
||||
repoId: string
|
||||
): SshWorktreeMetaIndex {
|
||||
return createSshWorktreeMetaIndex(
|
||||
Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId)
|
||||
)
|
||||
}
|
||||
|
||||
function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo {
|
||||
return {
|
||||
path,
|
||||
|
|
@ -842,10 +856,15 @@ function listDisconnectedSshWorktrees(
|
|||
if (Object.keys(ownershipUpdates).length > 0) {
|
||||
store.setWorktreeMeta(candidate.id, ownershipUpdates)
|
||||
}
|
||||
// Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename()
|
||||
// applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must
|
||||
// stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic
|
||||
// basename instead.
|
||||
const worktree = mergeWorktree(
|
||||
repo.id,
|
||||
synthesizeSshGitWorktree(repo, candidate.path, meta),
|
||||
meta
|
||||
meta,
|
||||
getWorktreePathBasenameFromId(candidate.id) ?? undefined
|
||||
)
|
||||
byWorktreeId.delete(worktree.id)
|
||||
byWorktreeId.set(worktree.id, worktree)
|
||||
|
|
@ -1826,6 +1845,7 @@ export function registerWorktreeHandlers(
|
|||
ipcMain.removeHandler('worktrees:listAll')
|
||||
ipcMain.removeHandler('worktrees:list')
|
||||
ipcMain.removeHandler('worktrees:listDetected')
|
||||
ipcMain.removeHandler('worktrees:listKnownForExecutionHost')
|
||||
ipcMain.removeHandler('worktrees:cancelListDetected')
|
||||
ipcMain.removeHandler('worktrees:create')
|
||||
ipcMain.removeHandler('worktrees:prefetchCreateBase')
|
||||
|
|
@ -1970,6 +1990,66 @@ export function registerWorktreeHandlers(
|
|||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:listKnownForExecutionHost',
|
||||
(_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => {
|
||||
// Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert —
|
||||
// it owns no repo, so every guard below still rejects it.
|
||||
const requestedRepoId = args?.repoId ?? ''
|
||||
const requestedExecutionHostId = args?.executionHostId ?? 'ssh:'
|
||||
const rejected = (): HostQualifiedKnownWorktreeResult => ({
|
||||
status: 'rejected',
|
||||
repoId: requestedRepoId,
|
||||
executionHostId: requestedExecutionHostId
|
||||
})
|
||||
const parsedHost = parseExecutionHostId(requestedExecutionHostId)
|
||||
if (parsedHost?.kind !== 'ssh') {
|
||||
return rejected()
|
||||
}
|
||||
// Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the
|
||||
// whole catalog, so a separate pass here is pure cost.
|
||||
const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId)
|
||||
if (!repo || repo.connectionId !== parsedHost.targetId) {
|
||||
return rejected()
|
||||
}
|
||||
const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({
|
||||
status: 'complete',
|
||||
repoId: repo.id,
|
||||
executionHostId: requestedExecutionHostId,
|
||||
result: {
|
||||
repoId: repo.id,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees
|
||||
}
|
||||
})
|
||||
// Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does.
|
||||
if (isFolderRepo(repo)) {
|
||||
const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) =>
|
||||
isFolderWorkspaceIdForRepo(repo, worktreeId)
|
||||
)
|
||||
return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)
|
||||
? rejected()
|
||||
: complete(
|
||||
// Why: match the authoritative folder listing; without lineage these rows render flat and then
|
||||
// reshuffle once the real scan lands.
|
||||
projectResolvedWorktreeLineage(
|
||||
buildFolderDetectedWorktrees(store, repo),
|
||||
store.getAllWorktreeLineage?.() ?? {}
|
||||
)
|
||||
)
|
||||
}
|
||||
const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id)
|
||||
return complete(
|
||||
buildDisconnectedDetectedWorktrees(
|
||||
store,
|
||||
repo,
|
||||
listDisconnectedSshWorktrees(store, repo, metaIndex)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:listDetected',
|
||||
async (
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text'
|
|||
import type { AppIdentity } from '../shared/app-identity'
|
||||
import type { ReleaseChannel } from '../shared/release-channel'
|
||||
import type {
|
||||
HostQualifiedKnownWorktreeResult,
|
||||
HostQualifiedDetectedWorktreeResult,
|
||||
LegacyDetectedWorktreeRequest,
|
||||
ListKnownWorktreesForExecutionHostArgs,
|
||||
ListDetectedWorktreesArgs,
|
||||
ProviderRequestId
|
||||
} from '../shared/detected-worktree-provider-contract'
|
||||
|
|
@ -1399,6 +1401,9 @@ export type PreloadApi = {
|
|||
): Promise<HostQualifiedDetectedWorktreeResult | DetectedWorktreeListResult>
|
||||
(args: LegacyDetectedWorktreeRequest): Promise<DetectedWorktreeListResult>
|
||||
}
|
||||
listKnownForExecutionHost?: (
|
||||
args: ListKnownWorktreesForExecutionHostArgs
|
||||
) => Promise<HostQualifiedKnownWorktreeResult>
|
||||
cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise<void>
|
||||
listAll: () => Promise<Worktree[]>
|
||||
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
|
||||
|
|
|
|||
|
|
@ -766,6 +766,9 @@ const api = {
|
|||
|
||||
listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args),
|
||||
|
||||
listKnownForExecutionHost: (args) =>
|
||||
ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args),
|
||||
|
||||
cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args),
|
||||
|
||||
listAll: () => ipcRenderer.invoke('worktrees:listAll'),
|
||||
|
|
|
|||
|
|
@ -68,6 +68,16 @@ describe('native preload SSH authority forwarding', () => {
|
|||
>()
|
||||
})
|
||||
|
||||
it('forwards host-qualified metadata-only worktree reads', async () => {
|
||||
await import('./index')
|
||||
const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi
|
||||
const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const }
|
||||
|
||||
await api.worktrees.listKnownForExecutionHost?.(args)
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args)
|
||||
})
|
||||
|
||||
it('forwards full-pair get and push states without cloning away authority', async () => {
|
||||
const state: SshConnectionState = {
|
||||
targetId: 'ssh-1',
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
buildWorkspaceSessionPayload,
|
||||
shouldPersistWorkspaceSession
|
||||
} from '@/lib/workspace-session'
|
||||
import { createTestStore, makeTab } from './store-test-helpers'
|
||||
import { createTestStore, makeTab, makeWorktree } from './store-test-helpers'
|
||||
|
||||
const WORKTREE_ID = 'repo1::/path/degraded'
|
||||
const TERMINAL_ID = 'terminal-degraded'
|
||||
|
|
@ -199,6 +199,39 @@ it('keeps a terminal-free degraded workspace selected through hydration and pers
|
|||
expect(restored.activeTabType).toBe('browser')
|
||||
})
|
||||
|
||||
it('keeps chrome when a non-authoritative fallback lists only some of the repo worktrees', () => {
|
||||
const store = createTestStore()
|
||||
// Why: an SSH metadata fallback can be non-empty yet partial (host-less metas are skipped on multi-owner
|
||||
// repos, agent-scratch stays hidden). A partial list must not read as proof the missing worktree was deleted.
|
||||
const sibling = makeWorktree({
|
||||
id: 'repo1::/path/sibling',
|
||||
repoId: 'repo1',
|
||||
path: '/path/sibling'
|
||||
})
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
|
||||
worktreesByRepo: { repo1: [sibling] },
|
||||
detectedWorktreesByRepo: {
|
||||
repo1: {
|
||||
repoId: 'repo1',
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: []
|
||||
}
|
||||
}
|
||||
})
|
||||
const session = makeDegradedRepoSession()
|
||||
store.getState().hydrateWorkspaceSession(session)
|
||||
store.getState().hydrateTabsSession(session)
|
||||
store.getState().hydrateEditorSession(session)
|
||||
store.getState().hydrateBrowserSession(session)
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([TERMINAL_ID])
|
||||
expect(state.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID])
|
||||
expect(state.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID])
|
||||
})
|
||||
|
||||
it('drops terminal-free chrome when an authoritative scan proves deletion', () => {
|
||||
const store = createTestStore()
|
||||
hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true)
|
||||
|
|
|
|||
|
|
@ -45,13 +45,21 @@ export function buildValidWorktreeIdsForSessionHydration(
|
|||
.map((worktree) => worktree.id)
|
||||
)
|
||||
const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id))
|
||||
const detectedWorktreesByRepo = catalog.detectedWorktreesByRepo ?? {}
|
||||
const repoIdsWithLoadedWorktrees = new Set(
|
||||
Object.entries(worktreesByRepo)
|
||||
.filter(([, worktrees]) => worktrees.length > 0)
|
||||
// Why (#1158): a metadata fallback can be non-empty yet partial (host-less metas are skipped on
|
||||
// multi-owner repos, agent-scratch stays hidden), so it cannot prove deletion the way a real listing can.
|
||||
// Only an explicitly non-authoritative result is disqualified; a repo with no detection entry at all
|
||||
// still counts as loaded, as before.
|
||||
.filter(
|
||||
([repoId, worktrees]) =>
|
||||
worktrees.length > 0 && detectedWorktreesByRepo[repoId]?.authoritative !== false
|
||||
)
|
||||
.map(([repoId]) => repoId)
|
||||
)
|
||||
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
|
||||
Object.entries(catalog.detectedWorktreesByRepo ?? {})
|
||||
Object.entries(detectedWorktreesByRepo)
|
||||
.filter(([, detected]) => detected?.authoritative)
|
||||
.map(([repoId]) => repoId)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp
|
|||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import type {
|
||||
HostQualifiedDetectedWorktreeResult,
|
||||
HostQualifiedKnownWorktreeResult,
|
||||
ListKnownWorktreesForExecutionHostArgs,
|
||||
ListDetectedWorktreesArgs
|
||||
} from '../../../../shared/detected-worktree-provider-contract'
|
||||
import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types'
|
||||
|
|
@ -105,12 +107,17 @@ const listDetectedMock = vi.fn<
|
|||
return qualifyDetectedResult(args, result)
|
||||
})
|
||||
|
||||
const listKnownForExecutionHostMock = vi.fn<
|
||||
(args: ListKnownWorktreesForExecutionHostArgs) => Promise<HostQualifiedKnownWorktreeResult>
|
||||
>(async (args) => ({ status: 'rejected', ...args }))
|
||||
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
create: vi.fn(),
|
||||
prefetchCreateBase: vi.fn().mockResolvedValue(undefined),
|
||||
list: worktreeListMock,
|
||||
listDetected: listDetectedMock,
|
||||
listKnownForExecutionHost: listKnownForExecutionHostMock,
|
||||
cancelListDetected: vi.fn().mockResolvedValue(undefined),
|
||||
listLineage: vi.fn().mockResolvedValue({}),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -153,6 +160,7 @@ import {
|
|||
createWorktreeSlice,
|
||||
getHostedReviewLinkMutationGenerationForTests,
|
||||
getHostedReviewLinkWorktreeAliasCountForTests,
|
||||
resetAuthoritativelyRemovedWorktreeMemoryForTests,
|
||||
resetHostedReviewLinkMutationGenerationForTests
|
||||
} from './worktrees'
|
||||
import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation'
|
||||
|
|
@ -439,6 +447,7 @@ describe('fetchWorktrees', () => {
|
|||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
clearHugeRepoWarningDismissalsForTests()
|
||||
resetAuthoritativelyRemovedWorktreeMemoryForTests()
|
||||
})
|
||||
|
||||
it('does not notify subscribers when the fetched payload is unchanged', async () => {
|
||||
|
|
@ -1350,6 +1359,365 @@ describe('fetchWorktrees', () => {
|
|||
expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo)
|
||||
})
|
||||
|
||||
it('shows persisted secondary worktrees while SSH is connecting', async () => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const queued = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/queued',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/queued',
|
||||
displayName: 'queued'
|
||||
})
|
||||
const detected = makeDetectedResult('repo-ssh', [queued], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
result: detected
|
||||
})
|
||||
store.setState({
|
||||
repos: [sshRepo],
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'ssh-1',
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
providerEpoch: null
|
||||
}
|
||||
]
|
||||
])
|
||||
} as Partial<AppState>)
|
||||
|
||||
await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false)
|
||||
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
|
||||
{ ...queued, hostId: 'ssh:ssh-1' }
|
||||
])
|
||||
expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds metadata rows without replacing richer cached SSH worktrees', async () => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const existing = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/existing',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/existing',
|
||||
hostId: 'ssh:ssh-1',
|
||||
head: 'live-head',
|
||||
branch: 'refs/heads/live-branch'
|
||||
})
|
||||
const metadataExisting = { ...existing, head: '', branch: '' }
|
||||
const queued = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/queued',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/queued'
|
||||
})
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
result: makeDetectedResult('repo-ssh', [metadataExisting, queued], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
})
|
||||
store.setState({
|
||||
repos: [sshRepo],
|
||||
sshConnectionStates: new Map(),
|
||||
worktreesByRepo: { [sshRepo.id]: [existing] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees(sshRepo.id)
|
||||
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
|
||||
existing,
|
||||
{ ...queued, hostId: 'ssh:ssh-1' }
|
||||
])
|
||||
})
|
||||
|
||||
it('inserts metadata rows inside the SSH block instead of past sibling hosts', async () => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined }
|
||||
const sshExisting = makeWorktree({
|
||||
id: 'repo-shared::/home/orca/existing',
|
||||
repoId: sshRepo.id,
|
||||
path: '/home/orca/existing',
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
const localExisting = makeWorktree({
|
||||
id: 'repo-shared::/local/existing',
|
||||
repoId: sshRepo.id,
|
||||
path: '/local/existing',
|
||||
hostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
const queued = makeWorktree({
|
||||
id: 'repo-shared::/home/orca/queued',
|
||||
repoId: sshRepo.id,
|
||||
path: '/home/orca/queued'
|
||||
})
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
result: makeDetectedResult(sshRepo.id, [queued], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
})
|
||||
store.setState({
|
||||
repos: [sshRepo, localRepo],
|
||||
sshConnectionStates: new Map(),
|
||||
worktreesByRepo: { [sshRepo.id]: [sshExisting, localExisting] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' })
|
||||
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
|
||||
sshExisting,
|
||||
{ ...queued, hostId: 'ssh:ssh-1' },
|
||||
localExisting
|
||||
])
|
||||
})
|
||||
|
||||
it('drops metadata rows when SSH authority lands during the read', async () => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const live = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/live',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/live',
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
// Why: deleted on the host, so an authoritative scan already purged it; the late metadata write must not resurrect it.
|
||||
const purged = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/purged',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/purged'
|
||||
})
|
||||
listKnownForExecutionHostMock.mockImplementationOnce(async (args) => {
|
||||
store.setState({
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
TEST_SSH_AUTHORITY.targetId,
|
||||
{
|
||||
targetId: TEST_SSH_AUTHORITY.targetId,
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
providerEpoch: TEST_SSH_AUTHORITY.providerEpoch,
|
||||
connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration
|
||||
}
|
||||
]
|
||||
]),
|
||||
worktreesByRepo: { [sshRepo.id]: [live] }
|
||||
} as Partial<AppState>)
|
||||
return {
|
||||
status: 'complete',
|
||||
repoId: args.repoId,
|
||||
executionHostId: args.executionHostId,
|
||||
result: makeDetectedResult(args.repoId, [live, purged], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
}
|
||||
})
|
||||
store.setState({
|
||||
repos: [sshRepo],
|
||||
sshConnectionStates: new Map()
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees(sshRepo.id)
|
||||
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
|
||||
})
|
||||
|
||||
it('replaces metadata rows once the authoritative SSH scan lands', async () => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const live = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/live',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/live',
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
const stale = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/stale',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/stale'
|
||||
})
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
result: makeDetectedResult(sshRepo.id, [stale], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
})
|
||||
const connectedStates = createTestStore().getState().sshConnectionStates
|
||||
store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees(sshRepo.id)
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
|
||||
{ ...stale, hostId: 'ssh:ssh-1' }
|
||||
])
|
||||
|
||||
worktreeListMock.mockResolvedValueOnce([live])
|
||||
store.setState({ sshConnectionStates: connectedStates } as Partial<AppState>)
|
||||
await store.getState().fetchWorktrees(sshRepo.id)
|
||||
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
|
||||
})
|
||||
|
||||
it('keeps the repo detection entry authoritative while appending metadata rows', async () => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined }
|
||||
const scanned = makeWorktree({
|
||||
id: 'repo-shared::/local/scanned',
|
||||
repoId: sshRepo.id,
|
||||
path: '/local/scanned'
|
||||
})
|
||||
const fromMetadata = makeWorktree({
|
||||
id: 'repo-shared::/home/orca/queued',
|
||||
repoId: sshRepo.id,
|
||||
path: '/home/orca/queued'
|
||||
})
|
||||
const authoritative = makeDetectedResult(sshRepo.id, [scanned])
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
result: makeDetectedResult(sshRepo.id, [fromMetadata], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
})
|
||||
store.setState({
|
||||
repos: [sshRepo, localRepo],
|
||||
sshConnectionStates: new Map(),
|
||||
detectedWorktreesByRepo: { [sshRepo.id]: authoritative }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' })
|
||||
|
||||
const detected = store.getState().detectedWorktreesByRepo[sshRepo.id]
|
||||
// Why: this entry is shared with the co-owning local host, so the fallback must not demote its scan.
|
||||
expect(detected?.authoritative).toBe(true)
|
||||
expect(detected?.source).toBe('git')
|
||||
expect(detected?.worktrees.map((worktree) => worktree.id)).toEqual([
|
||||
scanned.id,
|
||||
fromMetadata.id
|
||||
])
|
||||
})
|
||||
|
||||
it('does not resurrect worktrees an authoritative SSH scan already removed', async () => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const live = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/live',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/live',
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
const deletedOnRemote = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/deleted',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/deleted'
|
||||
})
|
||||
const metadataResult = () => ({
|
||||
status: 'complete' as const,
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1' as const,
|
||||
result: makeDetectedResult(sshRepo.id, [deletedOnRemote], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
})
|
||||
const connectedStates = createTestStore().getState().sshConnectionStates
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult())
|
||||
store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees(sshRepo.id)
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
|
||||
{ ...deletedOnRemote, hostId: 'ssh:ssh-1' }
|
||||
])
|
||||
|
||||
// The host connects and an authoritative scan proves the worktree is gone.
|
||||
worktreeListMock.mockResolvedValueOnce([live])
|
||||
store.setState({ sshConnectionStates: connectedStates } as Partial<AppState>)
|
||||
await store.getState().fetchWorktrees(sshRepo.id)
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
|
||||
|
||||
// The host drops again; persisted metadata still lists the deleted worktree.
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult())
|
||||
store.setState({ sshConnectionStates: new Map() } as Partial<AppState>)
|
||||
await store.getState().fetchWorktrees(sshRepo.id)
|
||||
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live])
|
||||
})
|
||||
|
||||
it('keeps worktree maps byte-identical for stale and malformed direct results', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
|
|
@ -7543,6 +7911,63 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
addedAt: 0
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'hydrates connecting SSH worktrees with hydration purge completed=%s',
|
||||
async (hasHydratedWorktreePurge) => {
|
||||
const store = createTestStore()
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const queued = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/queued',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/orca/queued',
|
||||
displayName: 'queued'
|
||||
})
|
||||
listKnownForExecutionHostMock.mockResolvedValueOnce({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
result: makeDetectedResult(sshRepo.id, [queued], {
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback'
|
||||
})
|
||||
})
|
||||
store.setState({
|
||||
repos: [sshRepo],
|
||||
hasHydratedWorktreePurge,
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'ssh-1',
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
providerEpoch: null
|
||||
}
|
||||
]
|
||||
])
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([
|
||||
{ ...queued, hostId: 'ssh:ssh-1' }
|
||||
])
|
||||
expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => {
|
||||
const store = createTestStore()
|
||||
const parent = makeWorktree({
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ import {
|
|||
import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression'
|
||||
import type {
|
||||
HostQualifiedDetectedWorktreeResult,
|
||||
HostQualifiedKnownWorktreeResult,
|
||||
ListDetectedWorktreesArgs,
|
||||
ProviderRequestId,
|
||||
SshExecutionHostId
|
||||
|
|
@ -2877,6 +2878,8 @@ function mergeFetchedWorktrees(
|
|||
args: FencedWorktreeMergeArgs
|
||||
): boolean {
|
||||
let admitted = false
|
||||
let authoritativelyRemovedIds: readonly string[] = []
|
||||
let authoritativelySeenIds: readonly string[] = []
|
||||
set((s) => {
|
||||
if (
|
||||
!isCurrentDetectedWorktreeRefresh(s, args.refresh) ||
|
||||
|
|
@ -2950,6 +2953,10 @@ function mergeFetchedWorktrees(
|
|||
args.refresh.result,
|
||||
args.hostId
|
||||
)
|
||||
authoritativelyRemovedIds = removedIds
|
||||
if (args.refresh.result.authoritative) {
|
||||
authoritativelySeenIds = args.refresh.result.worktrees.map((worktree) => worktree.id)
|
||||
}
|
||||
const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees)
|
||||
const detectedChanged = !areDetectedWorktreeResultsEqual(
|
||||
s.detectedWorktreesByRepo[args.repoId],
|
||||
|
|
@ -2979,9 +2986,210 @@ function mergeFetchedWorktrees(
|
|||
...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {})
|
||||
}
|
||||
})
|
||||
if (admitted) {
|
||||
// Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory.
|
||||
forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds)
|
||||
rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds)
|
||||
}
|
||||
return admitted
|
||||
}
|
||||
|
||||
// Why: an authoritative scan is the only proof a remote worktree is gone, but SSH WorktreeMeta is exempt from
|
||||
// gcStaleWorktreeMeta (persistence.ts:407,415), so without this memory the metadata fallback re-appends every
|
||||
// deleted row on the next disconnect — forever.
|
||||
const AUTHORITATIVE_REMOVAL_MEMORY_LIMIT = 512
|
||||
const authoritativelyRemovedWorktreeIdsByHost = new Map<ExecutionHostId, Set<string>>()
|
||||
|
||||
function rememberAuthoritativelyRemovedWorktrees(
|
||||
hostId: ExecutionHostId,
|
||||
worktreeIds: readonly string[]
|
||||
): void {
|
||||
if (worktreeIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set<string>()
|
||||
for (const worktreeId of worktreeIds) {
|
||||
// Why: re-insert so a re-removed id moves to the back of the insertion order the cap evicts from.
|
||||
removed.delete(worktreeId)
|
||||
removed.add(worktreeId)
|
||||
}
|
||||
for (const oldest of removed) {
|
||||
if (removed.size <= AUTHORITATIVE_REMOVAL_MEMORY_LIMIT) {
|
||||
break
|
||||
}
|
||||
removed.delete(oldest)
|
||||
}
|
||||
authoritativelyRemovedWorktreeIdsByHost.set(hostId, removed)
|
||||
}
|
||||
|
||||
// Why: a remote path can be recreated, so a scan that reports it again retracts the deletion verdict.
|
||||
function forgetAuthoritativelyRemovedWorktrees(
|
||||
hostId: ExecutionHostId,
|
||||
worktreeIds: Iterable<string>
|
||||
): void {
|
||||
const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId)
|
||||
if (!removed) {
|
||||
return
|
||||
}
|
||||
for (const worktreeId of worktreeIds) {
|
||||
removed.delete(worktreeId)
|
||||
}
|
||||
if (removed.size === 0) {
|
||||
authoritativelyRemovedWorktreeIdsByHost.delete(hostId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: module-level removal memory would otherwise leak across cases in one file. */
|
||||
export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void {
|
||||
authoritativelyRemovedWorktreeIdsByHost.clear()
|
||||
}
|
||||
|
||||
function appendMissingWorktreesForHost<
|
||||
T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string }
|
||||
>(
|
||||
current: readonly T[] | undefined,
|
||||
incoming: readonly T[],
|
||||
hostId: ExecutionHostId,
|
||||
options: WorktreeHostMatchOptions
|
||||
): T[] {
|
||||
const existing = current ?? []
|
||||
const existingHostIds = new Set(
|
||||
existing
|
||||
.filter((worktree) => worktreeMatchesHost(worktree, hostId, options))
|
||||
.map(({ id }) => id)
|
||||
)
|
||||
const missing = incoming.filter((worktree) => !existingHostIds.has(worktree.id))
|
||||
if (missing.length === 0) {
|
||||
return [...existing]
|
||||
}
|
||||
// Why: land inside the host's block like mergeWorktreesForHost does, else these rows sit past sibling hosts and visibly jump once the authoritative scan splices them back.
|
||||
const lastHostIndex = existing.findLastIndex((worktree) =>
|
||||
worktreeMatchesHost(worktree, hostId, options)
|
||||
)
|
||||
if (lastHostIndex === -1) {
|
||||
return [...existing, ...missing]
|
||||
}
|
||||
return [...existing.slice(0, lastHostIndex + 1), ...missing, ...existing.slice(lastHostIndex + 1)]
|
||||
}
|
||||
|
||||
function isAdmittedKnownSshWorktreeResult(
|
||||
result: HostQualifiedKnownWorktreeResult,
|
||||
repoId: string,
|
||||
executionHostId: SshExecutionHostId
|
||||
): result is Extract<HostQualifiedKnownWorktreeResult, { status: 'complete' }> {
|
||||
return (
|
||||
result.status === 'complete' &&
|
||||
result.repoId === repoId &&
|
||||
result.executionHostId === executionHostId &&
|
||||
isDetectedWorktreeListResult(result.result) &&
|
||||
result.result.repoId === repoId &&
|
||||
result.result.authoritative === false
|
||||
)
|
||||
}
|
||||
|
||||
const inflightKnownSshWorktreeFetches = new Map<
|
||||
string,
|
||||
Promise<DetectedWorktreeListResult | null>
|
||||
>()
|
||||
|
||||
// Why: the authoritative path dedupes through listDetectedWorktreesForRepoCoalesced; without a matching guard
|
||||
// the four refresh triggers can each issue this IPC and its merge for the same repo/host.
|
||||
async function fetchKnownSshWorktreesForRepo(
|
||||
set: Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[0],
|
||||
repoId: string,
|
||||
executionHostId: SshExecutionHostId
|
||||
): Promise<DetectedWorktreeListResult | null> {
|
||||
const coalesceKey = `${repoId} | ||||