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:
Jinjing 2026-08-05 16:52:49 -07:00 committed by GitHub
parent ae1ed5e886
commit 2ff2a1b268
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 944 additions and 6 deletions

View File

@ -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',

View File

@ -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 (

View File

@ -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>

View File

@ -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'),

View File

@ -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',

View File

@ -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)

View File

@ -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)
)

View File

@ -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({

View File

@ -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}${executionHostId}`
const inflight = inflightKnownSshWorktreeFetches.get(coalesceKey)
if (inflight) {
return await inflight
}
const request = runKnownSshWorktreeFetch(set, repoId, executionHostId).finally(() => {
inflightKnownSshWorktreeFetches.delete(coalesceKey)
})
inflightKnownSshWorktreeFetches.set(coalesceKey, request)
return await request
}
async function runKnownSshWorktreeFetch(
set: Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[0],
repoId: string,
executionHostId: SshExecutionHostId
): Promise<DetectedWorktreeListResult | null> {
// Why: reads the local store only, so a runtime-hub session (whose repo ids live on the hub) always gets 'rejected' and keeps the pre-existing no-op.
const listKnown = window.api.worktrees.listKnownForExecutionHost
if (typeof listKnown !== 'function') {
return null
}
const result = await listKnown({ repoId, executionHostId })
if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) {
return null
}
// Why: persisted SSH metadata outlives the remote worktree, so drop rows a completed scan already proved gone.
const suppressedIds = authoritativelyRemovedWorktreeIdsByHost.get(executionHostId)
const known =
suppressedIds && suppressedIds.size > 0
? {
...result.result,
worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id))
}
: result.result
let admitted = false
set((state) => {
// Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees.
if (
getCurrentDirectSshAuthority(state, executionHostId) ||
!repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false)
) {
return state
}
admitted = true
const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId)
const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId)
const incomingDetected = known.worktrees.map((worktree) =>
withRepoHostOwnership(worktree, executionHostId, setup)
)
const priorDetected = state.detectedWorktreesByRepo[repoId]
// Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's
// authoritative/source would demote a sibling host's completed scan and blank every authoritative-gated surface.
const detected = {
...(priorDetected ?? known),
worktrees: appendMissingWorktreesForHost(
priorDetected?.worktrees,
incomingDetected,
executionHostId,
matchOptions
)
}
const worktrees = appendMissingWorktreesForHost(
state.worktreesByRepo[repoId],
toVisibleWorktrees(known, executionHostId, setup),
executionHostId,
matchOptions
)
const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees)
const detectedChanged = !areDetectedWorktreeResultsEqual(
state.detectedWorktreesByRepo[repoId],
detected
)
if (!worktreesChanged && !detectedChanged) {
return state
}
return {
...(worktreesChanged
? {
worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees },
sortEpoch: state.sortEpoch + 1
}
: {}),
...(detectedChanged
? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } }
: {})
}
})
return admitted ? known : null
}
export type DirectSshDetectedWorktreeRefresh = {
waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId']
providerRequestId: ProviderRequestId
@ -3112,6 +3320,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
// Why: this function's contract is detected-only. The fallback runs for its store side effect, but
// callers keep seeing null as they did before the metadata path existed.
await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id)
return null
}
const refresh = await listDetectedWorktreesForRepoCoalesced(
@ -3210,6 +3421,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
// Why: requireAuthoritative callers asked for authoritative-or-nothing, so writing non-authoritative
// rows as a side effect before returning false would silently weaken that contract.
if (!options?.requireAuthoritative) {
await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id)
}
return false
}
const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, {
@ -3274,6 +3490,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id)
return
}
const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, {
@ -3325,6 +3542,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined)
: undefined
if (parsedHost?.kind === 'ssh' && !directSshAuthority) {
await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id)
return { repoId: r.id, ok: false as const }
}
const refresh = await listDetectedWorktreesForRepoCoalesced(
@ -4082,6 +4300,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement.
forgetHugeRepoWarningDismissalsForWorktrees([worktreeId])
// Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an
// in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it.
if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') {
rememberAuthoritativelyRemovedWorktrees(hostId, [worktreeId])
}
const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim()
if (worktreeDisplayName) {

View File

@ -23,6 +23,24 @@ export type ListDetectedWorktreesArgs =
| LocalDetectedWorktreeRequest
| DirectSshDetectedWorktreeRequest
export type ListKnownWorktreesForExecutionHostArgs = {
repoId: string
executionHostId: SshExecutionHostId
}
export type HostQualifiedKnownWorktreeResult =
| {
status: 'complete'
repoId: string
executionHostId: SshExecutionHostId
result: DetectedWorktreeListResult
}
| {
status: 'rejected'
repoId: string
executionHostId: SshExecutionHostId
}
export type AuthoritativeDetectedWorktreeHost =
| {
kind: 'local'