mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
feat(providers): dispatch git and filesystem providers by execution host
`const c = repo.connectionId; c ? sshProvider(c) : local()` overloads `null` to mean both "resolved: local" and "could not resolve", so every path that cannot determine the host silently runs remote work on the client (#11163). It also cannot express a `runtime:` host at all. Add a host-keyed dispatch whose input is an `ExecutionHostId` — never null — with `local`, `ssh` and `runtime` as three symmetric entries, and which throws on an id that names no host instead of degrading to this machine. `ssh` carries `provider: null` for "remote, currently unreachable", which is now a different answer from "local" rather than the same one. `runtime:` is a distinct entry rather than a provider because main does not execute runtime hosts at all: they are forwarded over the environment transport, and a runtime row's `connectionId` names a target in the server's namespace. Dialing it from this client's SSH table would trade a silent-local bug for a silent-wrong-host one. First migrations, both to rows resolved via `getRepoExecutionHostId`: - repo-worktrees: an `executionHostId: 'ssh:*'`-only row no longer lists, root-matches, or strict-lists against a same-named local path. - workspace-space-repo-scan: same for the size scan, and `isRemote` no longer contradicts the `executionHostId` emitted beside it.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ExecutionHostNotDispatchableError,
|
||||
requireFilesystemProviderForHost,
|
||||
requireGitProviderForHost,
|
||||
resolveFilesystemRouteForHost,
|
||||
resolveGitRouteForHost,
|
||||
UnresolvableExecutionHostError
|
||||
} from './execution-host-provider-dispatch'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from './ssh-git-dispatch'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from './ssh-filesystem-dispatch'
|
||||
|
||||
const connectionId = 'host-dispatch-target'
|
||||
const gitProvider = { listWorktrees: async () => [] } as never
|
||||
const filesystemProvider = { readDir: async () => [] } as never
|
||||
|
||||
describe('execution host provider dispatch', () => {
|
||||
afterEach(() => {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
unregisterSshFilesystemProvider(connectionId)
|
||||
})
|
||||
|
||||
it('routes `local` to the local entry rather than to a provider', () => {
|
||||
expect(resolveGitRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' })
|
||||
expect(resolveFilesystemRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' })
|
||||
})
|
||||
|
||||
it('routes an ssh host to its registered provider', () => {
|
||||
registerSshGitProvider(connectionId, gitProvider)
|
||||
registerSshFilesystemProvider(connectionId, filesystemProvider)
|
||||
|
||||
expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toEqual({
|
||||
kind: 'ssh',
|
||||
hostId: `ssh:${connectionId}`,
|
||||
connectionId,
|
||||
provider: gitProvider
|
||||
})
|
||||
expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toEqual({
|
||||
kind: 'ssh',
|
||||
hostId: `ssh:${connectionId}`,
|
||||
connectionId,
|
||||
provider: filesystemProvider
|
||||
})
|
||||
expect(requireGitProviderForHost(`ssh:${connectionId}`)).toBe(gitProvider)
|
||||
expect(requireFilesystemProviderForHost(`ssh:${connectionId}`)).toBe(filesystemProvider)
|
||||
})
|
||||
|
||||
it('answers `unreachable`, not `local`, for an ssh host with no registered provider', () => {
|
||||
const route = resolveGitRouteForHost(`ssh:${connectionId}`)
|
||||
|
||||
// The distinction the old `connectionId ? ssh : local` shape could not spell.
|
||||
expect(route.kind).toBe('ssh')
|
||||
expect(route.kind === 'ssh' && route.provider).toBeNull()
|
||||
expect(() => requireGitProviderForHost(`ssh:${connectionId}`)).toThrow(
|
||||
/Remote connection dropped/
|
||||
)
|
||||
expect(() => requireFilesystemProviderForHost(`ssh:${connectionId}`)).toThrow(
|
||||
/Remote connection dropped/
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a runtime host to its own entry instead of collapsing it into local', () => {
|
||||
expect(resolveGitRouteForHost('runtime:env-7')).toEqual({
|
||||
kind: 'runtime',
|
||||
hostId: 'runtime:env-7',
|
||||
environmentId: 'env-7'
|
||||
})
|
||||
expect(resolveFilesystemRouteForHost('runtime:env-7')).toEqual({
|
||||
kind: 'runtime',
|
||||
hostId: 'runtime:env-7',
|
||||
environmentId: 'env-7'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to hand a runtime host to this process’s ssh table', () => {
|
||||
// A runtime repo row carries the *server's* nested target id. Dialling it here would reach a
|
||||
// same-named target in this client's namespace.
|
||||
registerSshGitProvider(connectionId, gitProvider)
|
||||
|
||||
expect(() => requireGitProviderForHost('runtime:env-7')).toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
expect(() => requireFilesystemProviderForHost('runtime:env-7')).toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to serve a local host from the remote-only accessor', () => {
|
||||
expect(() => requireGitProviderForHost('local')).toThrow(ExecutionHostNotDispatchableError)
|
||||
expect(() => requireFilesystemProviderForHost('local')).toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
})
|
||||
|
||||
it.each([null, undefined, '', 'nonsense', 'ssh:', 'runtime:', 'ssh:a|b'])(
|
||||
'throws instead of answering local for the unresolvable host %p',
|
||||
(hostId) => {
|
||||
expect(() => resolveGitRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError)
|
||||
expect(() => resolveFilesystemRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Host-keyed provider dispatch: one entry per execution host kind, with `local` among them.
|
||||
*
|
||||
* The incumbent spelling across main is `const c = repo.connectionId; c ? sshProvider(c) : local()`,
|
||||
* where `null` means *both* "resolved: this is local" and "could not resolve". Every path that
|
||||
* cannot determine the host therefore answers "local" and runs remote work on the client — the
|
||||
* #11163 defect class, which has produced a reproduced cross-host leak (an `ssh:` worktree
|
||||
* resolving to another target) and near-misses where a transcript that exists only on a remote host
|
||||
* would have been read locally. The shape also cannot express a `runtime:` host at all.
|
||||
*
|
||||
* This module removes that spelling. Its input is an `ExecutionHostId`, which is never null, and an
|
||||
* id that names no host throws instead of degrading. `getRepoExecutionHostId` /
|
||||
* `getWorktreeExecutionHostId` / `resolveWorktreeExecutionHost` are the resolution layer that feeds
|
||||
* it; the last one already answers `unresolved` as a distinct verdict rather than "local".
|
||||
*
|
||||
* Why a route union rather than a uniform `getGitProviderForHost(): IGitProvider`, which is the
|
||||
* VS Code shape (`registerProvider(Schemas.file, …)` symmetric with `Schemas.vscodeRemote`, and
|
||||
* `ENOPRO` when nothing matches). Two properties of this process, not style preferences:
|
||||
*
|
||||
* - `local` git and filesystem work is free functions taking per-worktree execution options
|
||||
* (`wslDistro`, `sharedLinkPaths`, admission tier), not an `IGitProvider`. There is no local
|
||||
* provider object to register, and a stateless one would silently drop WSL routing.
|
||||
* - `runtime:<env>` is not executed in this process *at all*. It is forwarded over the
|
||||
* environment's transport (`runtimeEnvironments:call`) and the receiving server normalizes it to
|
||||
* its own `local`. A repo row on a runtime host carries the server's *nested* SSH target in
|
||||
* `connectionId`; that id is addressable only as the pair (environmentId, targetId). Handing it
|
||||
* to this client's SSH table would dial a same-named target in the wrong namespace — turning a
|
||||
* silent-local bug into a silent-wrong-host bug. `host-repo-catalog-snapshot` and
|
||||
* `host-qualified-worktree-listing` already reject runtime hosts for the same reason.
|
||||
*
|
||||
* So the answer is Zed's shape — an enum on the owner (`Local { fs }` vs `Remote { … }`) — and the
|
||||
* three kinds are symmetric variants of it. Callers switch exhaustively, so `runtime` can no longer
|
||||
* collapse into `local` by omission.
|
||||
*
|
||||
* Note the deliberate second distinction inside the `ssh` variant: `provider: null` means "this host
|
||||
* is remote and currently unreachable", which is not the same answer as "this host is local" and can
|
||||
* no longer be spelled the same way. That mirrors the `live` / `unverifiable` / `exited` rule in
|
||||
* docs/reference/ssh-execution-boundary.md — loss of contact is never evidence of locality.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId,
|
||||
type LOCAL_EXECUTION_HOST_ID,
|
||||
type ParsedExecutionHost
|
||||
} from '../../shared/execution-host'
|
||||
import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './ssh-git-dispatch'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from './ssh-filesystem-dispatch'
|
||||
import type { IFilesystemProvider, IGitProvider } from './types'
|
||||
|
||||
/** An id that names no execution host. Never degrade to local — that is the whole defect class. */
|
||||
export class UnresolvableExecutionHostError extends Error {
|
||||
constructor(readonly hostId: string | null | undefined) {
|
||||
super(
|
||||
`Cannot route work: ${JSON.stringify(hostId ?? null)} names no execution host. ` +
|
||||
'Refusing to fall back to this machine.'
|
||||
)
|
||||
this.name = 'UnresolvableExecutionHostError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Asking this process for a host it does not execute is a routing mistake, not a fallback. */
|
||||
export class ExecutionHostNotDispatchableError extends Error {
|
||||
constructor(readonly hostId: ExecutionHostId) {
|
||||
super(`Execution host ${hostId} is not dispatched by this process.`)
|
||||
this.name = 'ExecutionHostNotDispatchableError'
|
||||
}
|
||||
}
|
||||
|
||||
type LocalRoute = { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID }
|
||||
type RuntimeRoute = { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string }
|
||||
type SshRoute<TProvider> = {
|
||||
kind: 'ssh'
|
||||
hostId: `ssh:${string}`
|
||||
connectionId: string
|
||||
/** `null` is "remote, currently unreachable" — never "local". */
|
||||
provider: TProvider | null
|
||||
}
|
||||
|
||||
export type ExecutionHostGitRoute = LocalRoute | RuntimeRoute | SshRoute<IGitProvider>
|
||||
export type ExecutionHostFilesystemRoute = LocalRoute | RuntimeRoute | SshRoute<IFilesystemProvider>
|
||||
|
||||
// Takes an unvalidated string rather than `ExecutionHostId`: validating is the point, and host
|
||||
// ids also arrive from persistence and IPC where the compiler cannot vouch for them.
|
||||
function parseRoutableHost(hostId: string | null | undefined): ParsedExecutionHost {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (!parsed) {
|
||||
throw new UnresolvableExecutionHostError(hostId)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function resolveGitRouteForHost(hostId: string | null | undefined): ExecutionHostGitRoute {
|
||||
const parsed = parseRoutableHost(hostId)
|
||||
switch (parsed.kind) {
|
||||
case 'local':
|
||||
return { kind: 'local', hostId: parsed.id }
|
||||
case 'ssh':
|
||||
return {
|
||||
kind: 'ssh',
|
||||
hostId: parsed.id,
|
||||
connectionId: parsed.targetId,
|
||||
provider: getSshGitProvider(parsed.targetId) ?? null
|
||||
}
|
||||
case 'runtime':
|
||||
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFilesystemRouteForHost(
|
||||
hostId: string | null | undefined
|
||||
): ExecutionHostFilesystemRoute {
|
||||
const parsed = parseRoutableHost(hostId)
|
||||
switch (parsed.kind) {
|
||||
case 'local':
|
||||
return { kind: 'local', hostId: parsed.id }
|
||||
case 'ssh':
|
||||
return {
|
||||
kind: 'ssh',
|
||||
hostId: parsed.id,
|
||||
connectionId: parsed.targetId,
|
||||
provider: getSshFilesystemProvider(parsed.targetId) ?? null
|
||||
}
|
||||
case 'runtime':
|
||||
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
|
||||
}
|
||||
}
|
||||
|
||||
/** For call sites that are structurally remote-only: local and runtime are both routing errors. */
|
||||
export function requireGitProviderForHost(hostId: string | null | undefined): IGitProvider {
|
||||
const route = resolveGitRouteForHost(hostId)
|
||||
if (route.kind !== 'ssh') {
|
||||
throw new ExecutionHostNotDispatchableError(route.hostId)
|
||||
}
|
||||
if (!route.provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return route.provider
|
||||
}
|
||||
|
||||
export function requireFilesystemProviderForHost(
|
||||
hostId: string | null | undefined
|
||||
): IFilesystemProvider {
|
||||
const route = resolveFilesystemRouteForHost(hostId)
|
||||
if (route.kind !== 'ssh') {
|
||||
throw new ExecutionHostNotDispatchableError(route.hostId)
|
||||
}
|
||||
if (!route.provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return route.provider
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({
|
||||
listWorktreeGraphMock: vi.fn(),
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
listRepoWorktreeGraph,
|
||||
listRepoWorktrees
|
||||
} from './repo-worktrees'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
|
||||
|
||||
describe('repo-worktrees', () => {
|
||||
beforeEach(() => {
|
||||
@@ -196,6 +198,63 @@ describe('repo-worktrees', () => {
|
||||
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// #11163: a row may spell its owner only as `executionHostId`. Reading `connectionId` answers
|
||||
// "local" for it and runs the listing against a same-named path on this machine.
|
||||
describe('rows that spell their owner only as executionHostId', () => {
|
||||
const sshOnlyRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/srv/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'git' as const,
|
||||
executionHostId: 'ssh:host-a' as const
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshGitProvider('host-a')
|
||||
unregisterSshGitProvider('nested-target')
|
||||
})
|
||||
|
||||
it('never lists an ssh-owned row with local git', async () => {
|
||||
await expect(listRepoWorktrees(sshOnlyRepo)).rejects.toThrow(WorktreeCatalogUnavailableError)
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists an ssh-owned row through its registered provider', async () => {
|
||||
const listWorktrees = vi.fn().mockResolvedValue([{ path: '/srv/repo' }])
|
||||
registerSshGitProvider('host-a', { listWorktrees } as never)
|
||||
|
||||
await expect(listRepoWorktrees(sshOnlyRepo)).resolves.toEqual([{ path: '/srv/repo' }])
|
||||
expect(listWorktrees).toHaveBeenCalledWith('/srv/repo')
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an ssh-owned root out of the local repo-root match', () => {
|
||||
expect(isRepoRoot([sshOnlyRepo], '/srv/repo')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects strict local listing for an ssh-owned row', async () => {
|
||||
await expect(listLocalRepoWorktreesStrict(sshOnlyRepo)).rejects.toThrow('remote repository')
|
||||
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to answer a runtime-owned row from a same-named local target', async () => {
|
||||
const listWorktrees = vi.fn().mockResolvedValue([{ path: '/wrong/host' }])
|
||||
registerSshGitProvider('nested-target', { listWorktrees } as never)
|
||||
|
||||
await expect(
|
||||
listRepoWorktrees({
|
||||
...sshOnlyRepo,
|
||||
executionHostId: 'runtime:env-7',
|
||||
connectionId: 'nested-target'
|
||||
})
|
||||
).rejects.toThrow(WorktreeCatalogUnavailableError)
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('treats Windows repo root casing differences as the same local root', () => {
|
||||
const repos = [
|
||||
{
|
||||
|
||||
+29
-11
@@ -2,7 +2,8 @@ import type { Repo } from '../shared/repo-types'
|
||||
import type { GitWorktreeInfo } from '../shared/worktree/types'
|
||||
import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch'
|
||||
import { areWorktreePathsEqual } from './ipc/worktree-logic'
|
||||
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
|
||||
|
||||
@@ -16,8 +17,12 @@ function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions |
|
||||
}
|
||||
|
||||
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
|
||||
// Why: `!repo.connectionId` matched a remote path against a local one for a row that spells its
|
||||
// owner only as `executionHostId: 'ssh:<target>'`. Resolve the host instead of reading one field.
|
||||
return repos.some(
|
||||
(repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget)
|
||||
(repo) =>
|
||||
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID &&
|
||||
areWorktreePathsEqual(repo.path, resolvedTarget)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,17 +46,24 @@ export async function listRepoWorktrees(
|
||||
if (isFolderRepo(repo)) {
|
||||
return [createFolderWorktree(repo)]
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
|
||||
if (route.kind === 'runtime') {
|
||||
// A runtime row's `connectionId` names a target in the *server's* namespace, not one this
|
||||
// client may dial. Reading it here would answer from a same-named local target.
|
||||
throw new WorktreeCatalogUnavailableError(
|
||||
`Worktree catalog unavailable for ${repo.path}: host ${route.hostId} is not reachable from this process.`
|
||||
)
|
||||
}
|
||||
if (route.kind === 'ssh') {
|
||||
// Why: runtime worktree resolution can run before SSH providers have reattached during startup.
|
||||
// Never fall back to local git against a server path, and never report the unreachable host as an
|
||||
// empty catalog (#14004) — callers treat a resolved listing as authoritative.
|
||||
if (!provider) {
|
||||
if (!route.provider) {
|
||||
throw new WorktreeCatalogUnavailableError(
|
||||
`Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.`
|
||||
`Worktree catalog unavailable for ${repo.path}: SSH connection "${route.connectionId}" is not connected.`
|
||||
)
|
||||
}
|
||||
return await provider.listWorktrees(repo.path)
|
||||
return await route.provider.listWorktrees(repo.path)
|
||||
}
|
||||
return hasLocalRepoWorktreeListOptions(options)
|
||||
? await listWorktrees(repo.path, options)
|
||||
@@ -72,9 +84,15 @@ export async function listRepoWorktreeGraph(
|
||||
if (isFolderRepo(repo)) {
|
||||
return [createFolderWorktree(repo)]
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
return provider ? await provider.listWorktrees(repo.path) : []
|
||||
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
|
||||
// An unreachable remote host answers `[]` here, unlike listRepoWorktrees above, which throws.
|
||||
// Preserved as-is: this call site's callers treat the graph as best-effort. The inconsistency is
|
||||
// real but is a separate behavior decision from resolving the host correctly.
|
||||
if (route.kind === 'runtime') {
|
||||
return []
|
||||
}
|
||||
if (route.kind === 'ssh') {
|
||||
return route.provider ? await route.provider.listWorktrees(repo.path) : []
|
||||
}
|
||||
return hasLocalRepoWorktreeListOptions(options)
|
||||
? await listWorktreeGraph(repo.path, options)
|
||||
@@ -85,7 +103,7 @@ export async function listLocalRepoWorktreesStrict(
|
||||
repo: Repo,
|
||||
options?: LocalRepoWorktreeListOptions
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
if (repo.connectionId) {
|
||||
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
|
||||
throw new Error('Cannot list worktrees for a remote repository')
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
|
||||
@@ -9,11 +9,13 @@ import type {
|
||||
WorkspaceSpaceWorktree
|
||||
} from '../shared/workspace-space-types'
|
||||
import { mapWithConcurrency } from '../shared/map-with-concurrency'
|
||||
import { getRepoExecutionHostId } from '../shared/execution-host'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import { readWorktreeMetaForHost } from './persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from './worktree-metadata-ownership'
|
||||
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import {
|
||||
resolveFilesystemRouteForHost,
|
||||
resolveGitRouteForHost
|
||||
} from './providers/execution-host-provider-dispatch'
|
||||
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
|
||||
import { mergeWorktree } from './ipc/worktree-logic'
|
||||
import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options'
|
||||
@@ -89,16 +91,25 @@ async function listWorktreesForSpaceScan(
|
||||
if (isFolderRepo(repo)) {
|
||||
return { ok: true, worktrees: [createFolderWorktree(repo)] }
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
// Why: the raw `connectionId` field answers "local" for a row that spells its owner only as
|
||||
// `executionHostId: 'ssh:<target>'`, which sizes a same-named path on this machine instead.
|
||||
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
|
||||
if (route.kind === 'runtime') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'unavailable',
|
||||
error: `Host ${route.hostId} is not reachable from this process.`
|
||||
}
|
||||
}
|
||||
if (route.kind === 'ssh') {
|
||||
if (!route.provider) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'unavailable',
|
||||
error: `SSH connection "${repo.connectionId}" is not connected.`
|
||||
error: `SSH connection "${route.connectionId}" is not connected.`
|
||||
}
|
||||
}
|
||||
const worktrees = await provider.listWorktrees(repo.path, { signal })
|
||||
const worktrees = await route.provider.listWorktrees(repo.path, { signal })
|
||||
throwIfWorkspaceSpaceScanAborted(signal)
|
||||
return { ok: true, worktrees }
|
||||
}
|
||||
@@ -175,7 +186,7 @@ export async function scanWorkspaceSpaceRepo(args: {
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
displayName: repo.displayName,
|
||||
path: repo.path,
|
||||
isRemote: Boolean(repo.connectionId),
|
||||
isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID,
|
||||
worktreeCount: 0,
|
||||
scannedWorktreeCount: 0,
|
||||
unavailableWorktreeCount: 1,
|
||||
@@ -193,7 +204,7 @@ export async function scanWorkspaceSpaceRepo(args: {
|
||||
{ totalWorktreeCount: progress.totalWorktreeCount + worktrees.length },
|
||||
options.onProgress
|
||||
)
|
||||
const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined
|
||||
const filesystemRoute = resolveFilesystemRouteForHost(getRepoExecutionHostId(repo))
|
||||
const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => {
|
||||
throwIfWorkspaceSpaceScanAborted(options.signal)
|
||||
reportProgress(
|
||||
@@ -204,33 +215,36 @@ export async function scanWorkspaceSpaceRepo(args: {
|
||||
},
|
||||
options.onProgress
|
||||
)
|
||||
const row = repo.connectionId
|
||||
? remoteProvider
|
||||
? await scanRemoteWorkspaceSpaceWorktree(
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
remoteProvider,
|
||||
limiters.remoteFallbackTraversal,
|
||||
options.signal
|
||||
const row =
|
||||
filesystemRoute.kind !== 'local'
|
||||
? filesystemRoute.kind === 'ssh' && filesystemRoute.provider
|
||||
? await scanRemoteWorkspaceSpaceWorktree(
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
filesystemRoute.provider,
|
||||
limiters.remoteFallbackTraversal,
|
||||
options.signal
|
||||
)
|
||||
: createUnavailableWorkspaceSpaceRow(
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
'unavailable',
|
||||
filesystemRoute.kind === 'ssh'
|
||||
? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.`
|
||||
: `Host ${filesystemRoute.hostId} is not reachable from this process.`
|
||||
)
|
||||
: await limiters.localWorktree(() =>
|
||||
scanLocalWorkspaceSpaceWorktree(
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
args.readLocalDuDepthOne,
|
||||
args.normalizeLocalDuPath,
|
||||
options.signal
|
||||
)
|
||||
)
|
||||
: createUnavailableWorkspaceSpaceRow(
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
'unavailable',
|
||||
`SSH filesystem for "${repo.connectionId}" is not connected.`
|
||||
)
|
||||
: await limiters.localWorktree(() =>
|
||||
scanLocalWorkspaceSpaceWorktree(
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
args.readLocalDuDepthOne,
|
||||
args.normalizeLocalDuPath,
|
||||
options.signal
|
||||
)
|
||||
)
|
||||
reportProgress(
|
||||
progress,
|
||||
{
|
||||
@@ -265,7 +279,7 @@ export async function scanWorkspaceSpaceRepo(args: {
|
||||
executionHostId: getRepoExecutionHostId(repo),
|
||||
displayName: repo.displayName,
|
||||
path: repo.path,
|
||||
isRemote: Boolean(repo.connectionId),
|
||||
isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID,
|
||||
worktreeCount: rows.length,
|
||||
...summary,
|
||||
error: null
|
||||
|
||||
Reference in New Issue
Block a user