refactor(providers): an ssh: route carries no provider and throws one typed error (#27477)

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 06:25:41 -07:00
committed by GitHub
co-authored by m4air
parent e9279b613e
commit ec14775f93
50 changed files with 285 additions and 1633 deletions
@@ -695,68 +695,17 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(onRenamed).not.toHaveBeenCalled()
})
it('uses the SSH git provider and remote generation target for remote worktrees', async () => {
getSshGitUsernameMock.mockResolvedValue('remote-user')
const provider = {
exec: vi.fn(gitResponder({ currentBranch: 'remote-user/Nautilus', hasUpstream: false })),
renameCurrentBranch: vi.fn(async () => undefined),
executeCommitMessagePlan: vi.fn()
}
getSshGitProviderMock.mockReturnValue(provider as never)
const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo
const { deps } = makeDeps({ getRepo: () => repo })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(prepareLocalEnvMock).not.toHaveBeenCalled()
expect(generateBranchNameMock).toHaveBeenCalledWith(
{ firstPrompt: 'Fix the auth bug', assistantMessage: undefined },
{ agentId: 'claude', model: 'm' },
expect.objectContaining({
kind: 'remote',
cwd: '/repo/wt',
missingBinaryLocation: 'remote PATH'
})
)
expect(computeBranchNameMock).toHaveBeenCalledWith('fix-auth', expect.anything(), 'remote-user')
expect(provider.exec).not.toHaveBeenCalledWith(['branch', '-m', 'you/fix-auth'], '/repo/wt')
expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth')
})
it('retries when the SSH provider is unavailable on the first working event', async () => {
const provider = {
exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })),
renameCurrentBranch: vi.fn(async () => undefined),
executeCommitMessagePlan: vi.fn()
}
getSshGitProviderMock.mockReturnValueOnce(undefined).mockReturnValue(provider as never)
const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo
it.each<[string, Repo]>([
['connectionId', { ...REMOTE_REPO_BASE, connectionId: 'ssh-1' }],
['executionHostId', { ...REMOTE_REPO_BASE, executionHostId: 'ssh:ssh-1' }]
])('retries without local git for a row naming its SSH owner by %s', async (_spelling, repo) => {
const { deps, onRenamed } = makeDeps({ getRepo: () => repo })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(onRenamed).not.toHaveBeenCalled()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth')
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('routes a row that names its SSH owner only as executionHostId to that target', async () => {
const provider = {
exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })),
renameCurrentBranch: vi.fn(async () => undefined),
executeCommitMessagePlan: vi.fn()
}
getSshGitProviderMock.mockReturnValue(provider)
const repo: Repo = { ...REMOTE_REPO_BASE, executionHostId: 'ssh:ssh-1' }
const { deps } = makeDeps({ getRepo: () => repo })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth')
expect(generateBranchNameMock).not.toHaveBeenCalled()
expect(onRenamed).not.toHaveBeenCalled()
})
it("never dials a paired server's nested SSH target from this client", async () => {
@@ -13,7 +13,7 @@ import {
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
import { gitExecFileAsync } from '../git/runner'
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
import { resolveLocalGitUsername } from '../git/git-username'
import { resolveGitRouteForHost } from '../providers/execution-host-provider-dispatch'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import {
@@ -182,13 +182,10 @@ async function runAutoRename(
if (route.kind === 'runtime') {
return stop(`branch is owned by ${route.hostId}`)
}
if (route.kind === 'ssh' && !route.provider) {
if (route.kind === 'ssh') {
return retry('ssh provider unavailable')
}
const provider = route.kind === 'ssh' ? route.provider : null
const exec: GitExec = provider
? (args) => provider.exec(args, worktreePath)
: (args) => gitExecFileAsync(args, { cwd: worktreePath })
const exec: GitExec = (args) => gitExecFileAsync(args, { cwd: worktreePath })
const currentBranch = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (!currentBranch || currentBranch === 'HEAD') {
@@ -218,9 +215,7 @@ async function runAutoRename(
}
const settings = deps.getSettings()
const hostKey = getCommitMessageModelDiscoveryHostKey(
route.kind === 'ssh' ? route.connectionId : null
)
const hostKey = getCommitMessageModelDiscoveryHostKey(null)
const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo)
if (!resolvedParams.ok) {
// Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card.
@@ -229,7 +224,7 @@ async function runAutoRename(
}
const params = resolvedParams.params
const target = await resolveGenerationTarget(worktreePath, params.agentId, provider, deps)
const target = await resolveGenerationTarget(worktreePath, params.agentId, null, deps)
if (!target) {
deps.setRenameError(worktreeId, 'Could not prepare the branch-name generation environment.')
return retry('could not prepare generation environment')
@@ -262,9 +257,7 @@ async function runAutoRename(
)
}
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: (await resolveLocalGitUsername(repo.path)) || null
const username = (await resolveLocalGitUsername(repo.path)) || null
// The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip it to avoid double-prefixing.
const slug = stripConfiguredBranchPrefix(
generated.slug,
@@ -288,9 +281,7 @@ async function runAutoRename(
return stop(`no distinct unique branch name for slug "${slug}"`, true)
}
await (provider
? provider.renameCurrentBranch(worktreePath, newBranch)
: renameCurrentBranch(exec, newBranch))
await renameCurrentBranch(exec, newBranch)
// resolveUniqueBranchName may append a collision suffix (`-2`, …), so derive names from the resolved leaf, not the slug.
const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1)
+6 -20
View File
@@ -13,6 +13,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
import {
assertRemoteUrlReadable,
isTransientGitProbeError,
@@ -71,16 +72,10 @@ describe('remote URL probe', () => {
it('reports a probe cut off by its deadline as unavailable, not as an answer', async () => {
const aborted = new Error('Request was cancelled')
aborted.name = 'AbortError'
getSshGitProviderMock.mockReturnValue({
exec: vi.fn(async () => {
throw aborted
})
})
gitExecFileAsyncMock.mockRejectedValue(aborted)
expect(isTransientGitProbeError(aborted)).toBe(true)
await expect(
assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' })
).rejects.toBe(aborted)
await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).rejects.toBe(aborted)
})
it('rethrows a timed-out local probe so callers can report unavailable', async () => {
@@ -97,20 +92,11 @@ describe('remote URL probe', () => {
await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).resolves.toBeUndefined()
})
it('keeps a failed SSH command unverifiable even without a transport error string', async () => {
const failure = new Error('relay request failed')
getSshGitProviderMock.mockReturnValue({ exec: vi.fn().mockRejectedValue(failure) })
it('does not turn an SSH target into a false readable result', async () => {
getSshGitProviderMock.mockReturnValue({ exec: vi.fn() })
await expect(
assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' })
).rejects.toBe(failure)
})
it('does not turn a missing SSH provider into a false readable result', async () => {
getSshGitProviderMock.mockReturnValue(null)
await expect(
assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' })
).rejects.toThrow('SSH Git provider unavailable')
).rejects.toThrow(SshHostNotServedError)
})
})
@@ -155,24 +155,6 @@ describe('getPRForBranch SSH execution boundary', () => {
expect(outcome).toMatchObject({ kind: 'no-pr' })
})
it('rev-parses HEAD through the SSH provider when it is registered', async () => {
const sshGitProvider = {
exec: vi.fn(async (args: string[]) =>
args[0] === 'rev-parse' && args[1] === 'HEAD'
? { stdout: `${MERGED_HEAD_OID}\n`, stderr: '' }
: { stdout: '', stderr: '' }
)
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
mockMergedBranchPRLookup()
const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(sshGitProvider.exec).toHaveBeenCalledWith(['rev-parse', 'HEAD'], '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(pr).toMatchObject({ number: 5875, state: 'merged', headSha: MERGED_HEAD_OID })
})
it('rev-parses HEAD through the local runtime for a WSL repository', async () => {
mockMergedBranchPRLookup()
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' })
@@ -227,25 +209,6 @@ describe('getPRForBranch SSH execution boundary', () => {
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
})
it('reads tracked upstreams through the SSH provider when it is registered', async () => {
const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
const sshGitProvider = {
exec: vi.fn(async (args: string[]) =>
args[0] === 'for-each-ref'
? { stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`, stderr: '' }
: { stdout: '', stderr: '' }
)
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
mockUpstreamOnlyPRLookup()
const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
expect(sshGitProvider.exec).toHaveBeenCalledWith(forEachRefArgs, '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(pr).toMatchObject({ number: 78, title: 'Hydrated upstream branch PR' })
})
it('reads tracked upstreams through the local runtime for a WSL repository', async () => {
const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
mockUpstreamOnlyPRLookup()
@@ -31,11 +31,9 @@ import { resetPRForBranchMocks } from './client-test-harness'
const {
ghExecFileAsyncMock,
getOwnerRepoMock,
getOwnerRepoForRemoteMock,
resolvePRRepositoryCandidatesMock,
gitExecFileAsyncMock,
getSshGitProviderMock
gitExecFileAsyncMock
} = clientMocks
describe('getPRForBranch', () => {
@@ -200,178 +198,4 @@ describe('getPRForBranch', () => {
{ cwd: '/repo-root' }
)
})
it('checks the tracked upstream branch through the SSH git provider', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({
stdout: 'local-created-from-pr\0origin/contributor/original\n',
stderr: ''
})
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 78,
title: 'SSH upstream branch PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/78',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
const pr = await getPRForBranch(
'/remote/repo-root',
'local-created-from-pr',
undefined,
'ssh-1'
)
expect(sshGitProvider.exec).toHaveBeenCalledWith(
['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'],
'/remote/repo-root'
)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', 'repos/acme/widgets/pulls?head=acme%3Acontributor%2Foriginal&state=all&per_page=1'],
{}
)
expect(pr).toMatchObject({ number: 78, title: 'SSH upstream branch PR' })
})
it('uses the same-name tracked upstream fork owner through the SSH git provider', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({
stdout: 'contributor/fix\0fork/contributor/fix\n',
stderr: ''
})
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
candidates: [
{ owner: 'stablyai', repo: 'orca' },
{ owner: 'origin-owner', repo: 'orca' }
],
headRepo: { owner: 'origin-owner', repo: 'orca' }
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 79,
title: 'SSH same-name fork PR',
state: 'open',
html_url: 'https://github.com/stablyai/orca/pull/79',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/fix', sha: 'same-name-ssh-head-oid' }
}
])
})
const pr = await getPRForBranch('/remote/repo-root', 'contributor/fix', undefined, 'ssh-1')
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/remote/repo-root', 'fork', 'ssh-1')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', 'repos/stablyai/orca/pulls?head=fork-owner%3Acontributor%2Ffix&state=all&per_page=1'],
{}
)
expect(pr).toMatchObject({
number: 79,
title: 'SSH same-name fork PR',
prRepo: { owner: 'stablyai', repo: 'orca' },
headRepo: { owner: 'fork-owner', repo: 'orca' }
})
})
it('caches positive tracked-upstream entries for unsigned SSH runtimes during PR refresh polling', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({
stdout: 'refs/heads/feature\0origin/contributor/original\n',
stderr: ''
})
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
expect(sshGitProvider.exec).toHaveBeenCalledTimes(1)
})
it('refreshes positive tracked-upstream entries for unsigned SSH runtimes after the TTL', async () => {
vi.useFakeTimers()
try {
const sshGitProvider = {
exec: vi
.fn()
.mockResolvedValueOnce({
stdout: 'refs/heads/feature\0origin/old-upstream\n',
stderr: ''
})
.mockResolvedValueOnce({
stdout: 'refs/heads/feature\0origin/contributor/original\n',
stderr: ''
})
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 81,
title: 'Fresh SSH upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/81',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
await vi.advanceTimersByTimeAsync(30_001)
const pr = await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
// Why: a found upstream PR also resolves the default branch (#26948); count snapshot probes only.
const snapshotProbes = sshGitProvider.exec.mock.calls.filter(([args]) =>
args.includes('refs/heads')
)
expect(snapshotProbes).toHaveLength(2)
expect(pr).toMatchObject({
number: 81,
title: 'Fresh SSH upstream PR'
})
} finally {
vi.useRealTimers()
}
})
})
@@ -39,7 +39,6 @@ const {
getOwnerRepoForRemoteMock,
resolvePRRepositoryCandidatesMock,
gitExecFileAsyncMock,
getSshGitProviderMock,
readLocalGitConfigSignatureMock
} = clientMocks
@@ -143,10 +142,6 @@ describe('getPRForBranch', () => {
})
it('releases tracked-upstream probe generations across runtime identities', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' })
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' })
@@ -155,15 +150,13 @@ describe('getPRForBranch', () => {
await getPRForBranch('/repo-root', 'feature', null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
await getPRForBranch('/repo-root', 'feature', null, 'ssh-1')
expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({
snapshots: 3,
snapshots: 2,
inFlight: 0,
generations: 0
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(sshGitProvider.exec).toHaveBeenCalledTimes(1)
})
it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => {
@@ -218,11 +218,7 @@ export async function getCurrentHeadOid(
connectionId?: string | null,
localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {}
): Promise<string | null> {
const route = requireReachableGitRoute(getConnectionExecutionHostId(connectionId))
if (route.kind === 'ssh') {
const result = await route.provider.exec(['rev-parse', 'HEAD'], repoPath)
return result.stdout.trim() || null
}
requireReachableGitRoute(getConnectionExecutionHostId(connectionId))
try {
const result = await gitExecFileAsync(['rev-parse', 'HEAD'], {
cwd: repoPath,
@@ -134,14 +134,7 @@ export async function probeTrackedUpstreamBranches(
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}> {
const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
const route = requireReachableGitRoute(getConnectionExecutionHostId(connectionId))
if (route.kind === 'ssh') {
const result = await route.provider.exec(args, repoPath)
return {
probeFailed: false,
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
}
}
requireReachableGitRoute(getConnectionExecutionHostId(connectionId))
try {
const result = await gitExecFileAsync(args, {
cwd: repoPath,
@@ -193,53 +193,6 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => {
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('preserves a one-shot SSH remote failure through candidate discovery', async () => {
const failure = new Error('relay request failed')
const provider = {
exec: vi
.fn()
.mockRejectedValueOnce(failure)
.mockResolvedValueOnce({ stdout: 'git@github.com:team/orca.git\n', stderr: '' })
}
getSshGitProviderMock.mockReturnValue(provider)
await expect(
getOwnerRepoForRemote(
'/remote/repo',
'origin',
'ssh-1',
{},
{
requireVerifiedSshProbe: true
}
)
).rejects.toBe(failure)
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(provider.exec).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('keeps failed SSH alias classification unverifiable', async () => {
const provider = sshProvider('')
getSshGitProviderMock.mockReturnValue(provider)
await expect(
getOwnerRepoForRemote(
'/remote/repo',
'origin',
'ssh-1',
{},
{
requireVerifiedSshProbe: true
}
)
).rejects.toThrow('Remote repository identity is unverifiable.')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('isolates the same alias across native and WSL runtimes', async () => {
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
commandExecFileAsyncMock.mockResolvedValueOnce({
@@ -9,7 +9,6 @@ import {
getBranchCompareMock,
getBranchDiffMock,
listWorktreesMock,
getSshGitProviderMock,
resetFilesystemIpcMocks
} from './filesystem-test-harness'
@@ -248,40 +247,4 @@ describe('registerFilesystemHandlers', () => {
{ admissionTier: 'interactive' }
)
})
it('forwards the pinned head through SSH branch diff queries', async () => {
const result = {
kind: 'text',
originalContent: 'left',
modifiedContent: 'right',
originalIsBinary: false,
modifiedIsBinary: false
}
const getBranchDiff = vi.fn().mockResolvedValue([result])
getSshGitProviderMock.mockReturnValue({ getBranchDiff })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:branchDiff')!(null, {
worktreePath: '/home/user/project',
compare: {
baseRef: 'origin/main',
baseOid: 'base-oid',
headOid: 'head-oid',
mergeBase: 'merge-base-oid'
},
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts',
connectionId: 'conn-1'
})
).resolves.toEqual(result)
expect(getBranchDiff).toHaveBeenCalledWith('/home/user/project', 'merge-base-oid', {
includePatch: true,
headOid: 'head-oid',
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
})
})
@@ -91,24 +91,8 @@ describe('git:conflictOperation ssh routing', () => {
detectConflictOperationMock.mockReset()
})
it('asks the connected host and never probes locally', async () => {
const detect = vi.fn().mockResolvedValue('rebase')
getSshGitProviderMock.mockReturnValue({ detectConflictOperation: detect })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:conflictOperation')!(null, {
worktreePath: '/remote/wt',
connectionId: 'ssh-1'
})
).resolves.toBe('rebase')
expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1')
expect(detect).toHaveBeenCalledWith('/remote/wt')
expect(detectConflictOperationMock).not.toHaveBeenCalled()
})
it('reports an unreachable host instead of falling back to this machine', async () => {
getSshGitProviderMock.mockReturnValue(undefined)
it('reports an SSH host as not served instead of falling back to this machine', async () => {
getSshGitProviderMock.mockReturnValue({ detectConflictOperation: vi.fn() })
registerFilesystemHandlers(store as never)
await expect(
@@ -6,7 +6,6 @@ import {
store,
WORKTREE_FEATURE_PATH,
commitChangesMock,
bulkDiscardChangesMock,
getSshGitProviderMock,
resolveCommitMessageSettingsMock,
generatePullRequestFieldsFromContextMock,
@@ -92,7 +91,7 @@ describe('registerFilesystemHandlers', () => {
})
})
it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => {
it('refuses ssh git:commit as not served instead of running local commitChanges', async () => {
const sshCommitMock = vi.fn().mockResolvedValue({ success: true })
getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock })
@@ -104,30 +103,14 @@ describe('registerFilesystemHandlers', () => {
message: 'feat: remote commit',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: true })
).rejects.toThrow(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
expect(sshCommitMock).toHaveBeenCalledWith('/remote/repo', 'feat: remote commit')
expect(sshCommitMock).not.toHaveBeenCalled()
expect(commitChangesMock).not.toHaveBeenCalled()
})
it('routes ssh git:remoteCommitUrl through the SSH provider', async () => {
const sha = '0123456789abcdef0123456789abcdef01234567'
const sshRemoteCommitUrlMock = vi.fn().mockResolvedValue('https://github.com/org/repo/commit/x')
getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:remoteCommitUrl')!(null, {
worktreePath: '/remote/repo',
sha,
connectionId: 'conn-1'
})
).resolves.toBe('https://github.com/org/repo/commit/x')
expect(sshRemoteCommitUrlMock).toHaveBeenCalledWith('/remote/repo', sha)
})
it('rejects git:remoteCommitUrl with a short hash before SSH dispatch', async () => {
const sshRemoteCommitUrlMock = vi.fn()
getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock })
@@ -145,38 +128,6 @@ describe('registerFilesystemHandlers', () => {
expect(sshRemoteCommitUrlMock).not.toHaveBeenCalled()
})
it('routes ssh git:bulkDiscard through the SSH provider', async () => {
const sshBulkDiscardMock = vi.fn().mockResolvedValue(undefined)
getSshGitProviderMock.mockReturnValue({ bulkDiscardChanges: sshBulkDiscardMock })
registerFilesystemHandlers(store as never)
await handlers.get('git:bulkDiscard')!(null, {
worktreePath: '/remote/repo',
filePaths: ['a.ts', 'b.ts'],
connectionId: 'conn-1'
})
expect(sshBulkDiscardMock).toHaveBeenCalledWith('/remote/repo', ['a.ts', 'b.ts'])
expect(bulkDiscardChangesMock).not.toHaveBeenCalled()
})
it('routes ssh git:fastForward through the SSH provider', async () => {
const sshFastForwardMock = vi.fn().mockResolvedValue(undefined)
const pushTarget = { remoteName: 'fork', branchName: 'feature/fix' }
getSshGitProviderMock.mockReturnValue({ fastForwardBranch: sshFastForwardMock })
registerFilesystemHandlers(store as never)
await handlers.get('git:fastForward')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1',
pushTarget
})
expect(sshFastForwardMock).toHaveBeenCalledWith('/remote/repo', pushTarget)
})
it('rejects git:commit with whitespace-only message and does not call commitChanges', async () => {
registerFilesystemHandlers(store as never)
@@ -71,6 +71,7 @@ vi.mock(
)
import { registerFilesystemHandlers } from './filesystem'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
import {
registerWorktreeRootsForRepo,
invalidateAuthorizedRootsCache
@@ -159,13 +160,9 @@ describe('registerFilesystemHandlers', () => {
})
})
it('forwards includeIgnored through local and SSH git status IPC', async () => {
it('forwards includeIgnored through local git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
@@ -173,23 +170,14 @@ describe('registerFilesystemHandlers', () => {
worktreePath: WORKTREE_FEATURE_PATH,
includeIgnored: true
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
includeIgnored: true
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'status',
includeIgnored: true
})
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
admissionTier: 'status',
includeIgnored: true
})
})
it('returns capped-state metadata unchanged across local and SSH status IPC', async () => {
it('returns capped-state metadata unchanged through local status IPC', async () => {
const cappedStatus = {
entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }],
conflictOperation: 'unknown',
@@ -198,28 +186,16 @@ describe('registerFilesystemHandlers', () => {
}
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue(cappedStatus)
const sshProvider = { getStatus: vi.fn().mockResolvedValue(cappedStatus) }
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
).resolves.toEqual(cappedStatus)
await expect(
handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
).resolves.toEqual(cappedStatus)
})
it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => {
it('forwards upstream-negative-cache bypass through local git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
@@ -227,84 +203,46 @@ describe('registerFilesystemHandlers', () => {
worktreePath: WORKTREE_FEATURE_PATH,
bypassEffectiveUpstreamNegativeCache: true
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
bypassEffectiveUpstreamNegativeCache: true
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'status',
includeIgnored: false,
bypassEffectiveUpstreamNegativeCache: true
})
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
admissionTier: 'status',
includeIgnored: false,
bypassEffectiveUpstreamNegativeCache: true
})
})
it('forwards line-stat reuse through local and SSH git status IPC', async () => {
it('forwards line-stat reuse through local git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
reuseLineStats: true
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
reuseLineStats: true
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'status',
includeIgnored: false,
reuseLineStats: true
})
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
admissionTier: 'status',
includeIgnored: false,
reuseLineStats: true
})
})
it('forwards a false line-stats request through local and SSH git status IPC', async () => {
it('forwards a false line-stats request through local git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
includeLineStats: false
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
includeLineStats: false
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'status',
includeIgnored: false,
includeLineStats: false
})
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
admissionTier: 'status',
includeIgnored: false,
includeLineStats: false
})
})
it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])(
@@ -353,13 +291,9 @@ describe('registerFilesystemHandlers', () => {
}
)
it('checks ignored paths through local and SSH git providers', async () => {
it('checks ignored paths through the local git provider', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js'])
const sshProvider = {
checkIgnoredPaths: vi.fn().mockResolvedValue(['build/output.js'])
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
@@ -369,66 +303,52 @@ describe('registerFilesystemHandlers', () => {
paths: ['dist/bundle.js', 'src/index.ts']
})
).resolves.toEqual(['dist/bundle.js'])
await expect(
handlers.get('git:checkIgnored')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
paths: ['build/output.js']
})
).resolves.toEqual(['build/output.js'])
expect(checkIgnoredPathsMock).toHaveBeenCalledWith(
WORKTREE_FEATURE_PATH,
[path.join('dist', 'bundle.js'), path.join('src', 'index.ts')],
{}
)
expect(sshProvider.checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', [
path.join('build', 'output.js')
])
})
it('routes abort merge through local and SSH git providers', async () => {
it('routes abort merge through the local git provider', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
abortMergeMock.mockResolvedValue(undefined)
const sshProvider = {
abortMerge: vi.fn().mockResolvedValue(undefined)
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:abortMerge')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
await handlers.get('git:abortMerge')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'interactive'
})
expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo')
})
it('routes abort rebase through local and SSH git providers', async () => {
it('routes abort rebase through the local git provider', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
abortRebaseMock.mockResolvedValue(undefined)
const sshProvider = {
abortRebase: vi.fn().mockResolvedValue(undefined)
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:abortRebase')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
await handlers.get('git:abortRebase')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
expect(abortRebaseMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'interactive'
})
expect(sshProvider.abortRebase).toHaveBeenCalledWith('/remote/repo')
})
it('refuses SSH git IPC with the not-served error and never runs it locally', async () => {
getSshGitProviderMock.mockReturnValue({ getStatus: vi.fn(), abortMerge: vi.fn() })
registerFilesystemHandlers(store as never)
for (const channel of ['git:status', 'git:abortMerge', 'git:abortRebase']) {
await expect(
handlers.get(channel)!(null, { worktreePath: '/remote/repo', connectionId: 'ssh-1' })
).rejects.toThrow(SshHostNotServedError)
}
expect(getStatusMock).not.toHaveBeenCalled()
expect(abortMergeMock).not.toHaveBeenCalled()
expect(abortRebaseMock).not.toHaveBeenCalled()
})
it('rejects git file paths that escape the selected worktree', async () => {
@@ -18,10 +18,7 @@ export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerCo
if (typeof args.message !== 'string' || args.message.trim().length === 0) {
throw new Error('Commit message is required')
}
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.commit(args.worktreePath, args.message)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -30,28 +30,7 @@ export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerCont
connectionId?: string
}
): Promise<GitDiffResult> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
const results = await route.provider.getBranchDiff(
args.worktreePath,
args.compare.mergeBase,
{
includePatch: true,
headOid: args.compare.headOid,
filePath: args.filePath,
oldPath: args.oldPath
}
)
return (
results[0] ?? {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false
}
)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
const oldPath = args.oldPath
@@ -90,15 +69,7 @@ export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerCont
): Promise<GitDiffResult> => {
const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid')
const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.getCommitDiff(args.worktreePath, {
commitOid,
parentOid,
filePath: args.filePath,
oldPath: args.oldPath
})
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
const oldPath = args.oldPath
@@ -28,10 +28,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon
_event,
args: { worktreePath: string; filePath: string; connectionId?: string }
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.stageFile(args.worktreePath, args.filePath)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -49,10 +46,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon
_event,
args: { worktreePath: string; filePath: string; connectionId?: string }
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.unstageFile(args.worktreePath, args.filePath)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -70,10 +64,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon
_event,
args: { worktreePath: string; filePath: string; connectionId?: string }
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.discardChanges(args.worktreePath, args.filePath)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -91,10 +82,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon
_event,
args: { worktreePath: string; filePaths: string[]; connectionId?: string }
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.bulkDiscardChanges(args.worktreePath, args.filePaths)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p))
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -116,10 +104,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon
args: { worktreePath: string; filePaths: string[]; connectionId?: string; scope?: unknown }
): Promise<void> => {
const scope = parseGitStageWorktreeScope(args.scope)
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.bulkStageFiles(args.worktreePath, args.filePaths, scope)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p))
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -149,10 +134,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon
_event,
args: { worktreePath: string; filePaths: string[]; connectionId?: string }
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.bulkUnstageFiles(args.worktreePath, args.filePaths)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p))
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -68,11 +68,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
...(controller ? { signal: controller.signal } : {})
}
try {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
// Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it).
return await route.provider.getStatus(args.worktreePath, options)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
// Why: one registered-worktree lookup feeds both — status polls this
// handler, and the scan walks every repo's worktree meta.
@@ -112,10 +108,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
area?: GitStagingArea
}
): Promise<GitStatusResult> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -135,11 +128,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
_event,
args: { worktreePath: string; paths: string[]; connectionId?: string }
): Promise<string[]> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p))
return route.provider.checkIgnoredPaths(args.worktreePath, paths)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p))
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -180,10 +169,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
args: { worktreePath: string; connectionId?: string } & GitHistoryOptions
): Promise<GitHistoryResult> => {
const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef }
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.getHistory(args.worktreePath, options)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -201,10 +187,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
_event,
args: { worktreePath: string; connectionId?: string }
): Promise<GitConflictOperation> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.detectConflictOperation(args.worktreePath)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -218,10 +201,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
ipcMain.handle(
'git:abortMerge',
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.abortMerge(args.worktreePath)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -235,10 +215,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
ipcMain.handle(
'git:abortRebase',
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.abortRebase(args.worktreePath)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -261,15 +238,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
connectionId?: string
}
): Promise<GitDiffResult> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.getDiff(
args.worktreePath,
args.filePath,
args.staged,
args.compareAgainstHead
)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -16,10 +16,7 @@ export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerConte
args: { worktreePath: string; relativePath: string; line: number; connectionId?: string }
): Promise<string | null> => {
// Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider.
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath })
return getRemoteFileUrl(worktreePath, args.relativePath, args.line)
@@ -34,10 +31,7 @@ export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerConte
): Promise<string | null> => {
const sha = validateFullGitObjectId(args.sha, 'sha')
// Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider.
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.getRemoteCommitUrl(args.worktreePath, sha)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath })
return getRemoteCommitUrl(worktreePath, sha)
@@ -4,11 +4,7 @@ import { gitFastForward, gitPull, gitPullRebaseFromBase, gitPush } from '../../.
import { validateGitPushTarget } from '../../../git/push-target-validation'
import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache'
import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options'
import { assertValidGitPushTarget } from '../../../../shared/git-push-target-validation'
import {
materializeWorktreePushTargetRemote,
materializeWorktreePushTargetRemoteSsh
} from '../../worktree-remote'
import { materializeWorktreePushTargetRemote } from '../../worktree-remote'
import type { FilesystemHandlerContext } from '../filesystem-handler-context'
import { requireReachableGitRoute } from '../../../providers/execution-host-provider-dispatch'
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
@@ -31,26 +27,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
): Promise<void> => {
// Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts.
const publish = args.publish === true
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
if (args.pushTarget) {
assertValidGitPushTarget(args.pushTarget)
}
// Why: a fork remote deferred at create time (#17828) must exist before push.
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
route.provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return route.provider.pushBranch(args.worktreePath, publish, materializedPushTarget, {
forceWithLease: args.forceWithLease === true
})
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -92,23 +69,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
pushTarget?: GitPushTarget
}
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
if (args.pushTarget) {
assertValidGitPushTarget(args.pushTarget)
}
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
route.provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return route.provider.pullBranch(args.worktreePath, materializedPushTarget)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -149,23 +110,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
pushTarget?: GitPushTarget
}
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
if (args.pushTarget) {
assertValidGitPushTarget(args.pushTarget)
}
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
route.provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return route.provider.fastForwardBranch(args.worktreePath, materializedPushTarget)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -201,10 +146,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
_event,
args: { worktreePath: string; baseRef: string; connectionId?: string }
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.rebaseFromBase(args.worktreePath, args.baseRef)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -26,14 +26,7 @@ export function registerGitRemoteCompareHandlers(context: FilesystemHandlerConte
admissionTier?: GitAdmissionTier
}
): Promise<GitBranchCompareResult> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return args.admissionTier
? route.provider.getBranchCompare(args.worktreePath, args.baseRef, {
admissionTier: args.admissionTier
})
: route.provider.getBranchCompare(args.worktreePath, args.baseRef)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -54,10 +47,7 @@ export function registerGitRemoteCompareHandlers(context: FilesystemHandlerConte
args: { worktreePath: string; commitId: string; connectionId?: string }
): Promise<GitCommitCompareResult> => {
const commitId = validateFullGitObjectId(args.commitId, 'commitId')
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.getCommitCompare(args.worktreePath, commitId)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -11,12 +11,8 @@ import { getUpstreamStatus } from '../../../git/upstream'
import { validateGitPushTarget } from '../../../git/push-target-validation'
import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache'
import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options'
import { assertValidGitPushTarget } from '../../../../shared/git-push-target-validation'
import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync'
import {
materializeWorktreePushTargetRemote,
materializeWorktreePushTargetRemoteSsh
} from '../../worktree-remote'
import { materializeWorktreePushTargetRemote } from '../../worktree-remote'
import type { FilesystemHandlerContext } from '../filesystem-handler-context'
import { requireReachableGitRoute } from '../../../providers/execution-host-provider-dispatch'
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
@@ -30,13 +26,7 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
_event,
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
): Promise<GitUpstreamStatus> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
if (args.pushTarget) {
assertValidGitPushTarget(args.pushTarget)
}
return route.provider.getUpstreamStatus(args.worktreePath, args.pushTarget)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -58,23 +48,7 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
pushTarget?: GitPushTarget
}
): Promise<void> => {
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
if (args.pushTarget) {
assertValidGitPushTarget(args.pushTarget)
}
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
route.provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return route.provider.fetchRemote(args.worktreePath, materializedPushTarget)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -117,10 +91,7 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, {
required: true
})
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return route.provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
@@ -29,6 +29,7 @@ vi.mock('./worktree-base-directory-watcher', () => ({
}))
import { applyGitStatusUpstreamRefWatchRequest } from './git-status-upstream-ref-watch-request'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
describe('applyGitStatusUpstreamRefWatchRequest', () => {
beforeEach(() => {
@@ -67,36 +68,28 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => {
)
})
it('routes SSH resolution through the current provider generation', async () => {
const exec = vi.fn().mockResolvedValue({
stdout:
'refs/heads/feature/main\0refs/remotes/team/fork/feature/main\0team/fork/feature/main\n'
})
it('keeps the SSH provider generation but refuses to resolve on an SSH host', async () => {
const exec = vi.fn()
mocks.getProvider.mockReturnValue({ exec })
mocks.getProviderGeneration.mockReturnValue(7)
await applyGitStatusUpstreamRefWatchRequest({} as Store, {
worktreeId: 'repo-1::/repo',
worktreePath: '/repo',
executionHostId: 'ssh:ssh-1',
connectionId: 'ssh-1',
branch: 'refs/heads/feature/main',
upstreamName: 'team/fork/feature/main'
})
await expect(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SSH refusal throws before any store read.
applyGitStatusUpstreamRefWatchRequest({} as Store, {
worktreeId: 'repo-1::/repo',
worktreePath: '/repo',
executionHostId: 'ssh:ssh-1',
connectionId: 'ssh-1',
branch: 'refs/heads/feature/main',
upstreamName: 'team/fork/feature/main'
})
).rejects.toThrow(SshHostNotServedError)
expect(mocks.setWatch).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'ssh-1', providerGeneration: 7 }),
expect.any(Function)
)
expect(exec).toHaveBeenCalledWith(
[
'for-each-ref',
'--format=%(refname)%00%(upstream)%00%(upstream:short)',
'--count=1',
'refs/heads/feature/main'
],
'/repo',
expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: 15_000 })
)
expect(exec).not.toHaveBeenCalled()
expect(mocks.gitExec).not.toHaveBeenCalled()
})
})
@@ -37,20 +37,7 @@ export function applyGitStatusUpstreamRefWatchRequest(
return undefined
}
const signal = boundedSignal(bindingSignal)
const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
if (route.kind === 'ssh') {
return resolveGitStatusUpstreamRef(
(gitArgs, cwd, requestSignal) =>
route.provider.exec(gitArgs, cwd, {
signal: requestSignal,
timeoutMs: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS
}),
args.worktreePath,
args.branch,
args.upstreamName,
signal
)
}
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)
-37
View File
@@ -206,43 +206,6 @@ describe('repos:addRemote', () => {
})
})
it.each([
['connectionId', { connectionId: 'conn-1' }],
['executionHostId only', { executionHostId: 'ssh:conn-1' }]
])(
'returns an existing SSH repo (%s) instead of cloning the same target again',
async (_label, owner) => {
const existing = {
id: 'existing-id',
path: '/home/user/orca',
...owner,
displayName: 'orca',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
// What git on the SSH host answers inside a finished clone of the URL.
mockGitProvider.exec.mockImplementation((argv: string[], cwd?: string) =>
cwd === '/home/user/orca' && argv[0] === 'rev-parse'
? Promise.resolve({ stdout: '\n0123abcd\n', stderr: '' })
: cwd === '/home/user/orca' && argv[0] === 'config'
? Promise.resolve({ stdout: 'https://github.com/stablyai/orca.git\n', stderr: '' })
: Promise.reject(new Error('fatal: not a git repository'))
)
const result = await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(result).toBe(existing)
expect(mockGitProvider.clone).not.toHaveBeenCalled()
expect(mockStore.addRepo).not.toHaveBeenCalled()
}
)
it('refuses a saved SSH project at the destination whose folder is not this clone', async () => {
mockStore.getRepos.mockReturnValue([
{
+2 -1
View File
@@ -27,6 +27,7 @@ import {
ExecutionHostNotDispatchableError,
resolveGitRouteForHost
} from '../providers/execution-host-provider-dispatch'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { SshGitProvider } from '../providers/ssh-git-provider'
export type WorkspaceCleanupGitRoute =
@@ -57,7 +58,7 @@ export function resolveWorkspaceCleanupRepoGitRoute(
kind: 'ssh',
hostId: route.hostId,
connectionId: route.connectionId,
provider: route.provider
provider: getSshGitProvider(route.connectionId) ?? null
}
case 'runtime':
throw new ExecutionHostNotDispatchableError(route.hostId)
@@ -1,8 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
ExecutionHostNotDispatchableError,
requireFilesystemProviderForHost,
requireGitProviderForHost,
requireReachableFilesystemRoute,
requireReachableGitRoute,
resolveFilesystemRouteForHost,
@@ -16,7 +14,7 @@ import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from './ssh-filesystem-dispatch'
import { clearSshPlainSshMode, setSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode'
import { SSH_HOST_NOT_SERVED_MESSAGE, SshHostNotServedError } from './ssh-host-not-served-error'
const connectionId = 'host-dispatch-target'
const gitProvider = { listWorktrees: async () => [] } as never
@@ -41,38 +39,13 @@ describe('execution host provider dispatch', () => {
})
})
it('routes an ssh host to its registered provider', () => {
it('routes an ssh host to a provider-less entry, even with a 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/
)
const expected = { kind: 'ssh', hostId: `ssh:${connectionId}`, connectionId }
expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected)
expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected)
})
it('routes a runtime host to its own entry instead of collapsing it into local', () => {
@@ -88,26 +61,6 @@ describe('execution host provider dispatch', () => {
})
})
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) => {
@@ -117,41 +70,36 @@ describe('execution host provider dispatch', () => {
)
describe('reachable routes for work this process runs', () => {
it('keeps `local` explicit and returns a connected ssh host with its provider', () => {
it('keeps `local` explicit', () => {
expect(requireReachableGitRoute('local')).toMatchObject({ kind: 'local', hostId: 'local' })
expect(requireReachableFilesystemRoute('local')).toMatchObject({ kind: 'local' })
registerSshGitProvider(connectionId, gitProvider)
registerSshFilesystemProvider(connectionId, filesystemProvider)
expect(requireReachableGitRoute(`ssh:${connectionId}`)).toMatchObject({
kind: 'ssh',
connectionId,
provider: gitProvider
})
expect(requireReachableFilesystemRoute(`ssh:${connectionId}`)).toMatchObject({
kind: 'ssh',
provider: filesystemProvider
})
})
it('throws for an unreachable ssh host instead of running it here', () => {
expect(() => requireReachableGitRoute(`ssh:${connectionId}`)).toThrow(
/Remote connection dropped/
)
expect(() => requireReachableFilesystemRoute(`ssh:${connectionId}`)).toThrow(
/Remote connection dropped/
)
})
it('names a plain SSH host’s missing git as unsupported, not dropped', () => {
setSshPlainSshMode(connectionId, { reason: 'no_runtime', message: 'm' })
try {
expect(() => requireReachableGitRoute(`ssh:${connectionId}`)).toThrow(
'Git needs the Orca remote server'
// Characterization: the message is the one an unreachable ssh host already showed.
it.each([false, true])(
'throws the typed not-served error for an ssh host (provider registered: %s)',
(registered) => {
if (registered) {
registerSshGitProvider(connectionId, gitProvider)
registerSshFilesystemProvider(connectionId, filesystemProvider)
}
for (const require of [requireReachableGitRoute, requireReachableFilesystemRoute]) {
expect(() => require(`ssh:${connectionId}`)).toThrow(SshHostNotServedError)
expect(() => require(`ssh:${connectionId}`)).toThrow(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
expect(SSH_HOST_NOT_SERVED_MESSAGE).toBe(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
} finally {
clearSshPlainSshMode(connectionId)
}
)
it('never hands an ssh host a local provider factory', () => {
expect(resolveGitRouteForHost(`ssh:${connectionId}`)).not.toHaveProperty('createProvider')
expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).not.toHaveProperty(
'createProvider'
)
})
it('refuses a runtime host', () => {
@@ -11,7 +11,8 @@
* options (WSL distro, shared links, admission tier) a shared provider would drop.
* - `runtime:<env>` is forwarded to that server. Its repo's `connectionId` names the server's SSH
* target, so it must never reach this client's SSH table.
* - `ssh` with `provider: null` means "remote and unreachable", never "local".
* - `ssh` names an SSH target this process does not serve; work on it throws
* `SshHostNotServedError`, never runs locally.
*/
import {
@@ -22,13 +23,7 @@ import {
} from '../../shared/execution-host'
import { createLocalFilesystemProvider } from './local-filesystem-provider'
import { createLocalGitProvider } from './local-git-provider'
import { getSshGitProvider, sshGitProviderMissingError } from './ssh-git-dispatch'
import type { SshGitProvider } from './ssh-git-provider'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from './ssh-filesystem-dispatch'
import type { IFilesystemProvider, IGitProvider } from './types'
import { SshHostNotServedError } from './ssh-host-not-served-error'
/** An id that names no execution host. Never degrade to local — that is the whole defect class. */
export class UnresolvableExecutionHostError extends Error {
@@ -55,21 +50,10 @@ type LocalGitRoute = LocalRoute & { createProvider: typeof createLocalGitProvide
/** Local file work authorizes against the caller's store, so the route carries a factory too. */
type LocalFilesystemRoute = LocalRoute & { createProvider: typeof createLocalFilesystemProvider }
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
}
type SshRoute = { kind: 'ssh'; hostId: `ssh:${string}`; connectionId: string }
// The SSH table stores `SshGitProvider`; narrowing the route to `IGitProvider` would drop the
// remote-only methods (commit-message plans, push-target materialization) that callers need.
export type ExecutionHostGitRoute = LocalGitRoute | RuntimeRoute | SshRoute<SshGitProvider>
export type ExecutionHostFilesystemRoute =
| LocalFilesystemRoute
| RuntimeRoute
| SshRoute<IFilesystemProvider>
export type ExecutionHostGitRoute = LocalGitRoute | RuntimeRoute | SshRoute
export type ExecutionHostFilesystemRoute = LocalFilesystemRoute | RuntimeRoute | SshRoute
// 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.
@@ -87,12 +71,7 @@ export function resolveGitRouteForHost(hostId: string | null | undefined): Execu
case 'local':
return { kind: 'local', hostId: parsed.id, createProvider: createLocalGitProvider }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshGitProvider(parsed.targetId) ?? null
}
return { kind: 'ssh', hostId: parsed.id, connectionId: parsed.targetId }
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
@@ -106,73 +85,32 @@ export function resolveFilesystemRouteForHost(
case 'local':
return { kind: 'local', hostId: parsed.id, createProvider: createLocalFilesystemProvider }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshFilesystemProvider(parsed.targetId) ?? null
}
return { kind: 'ssh', hostId: parsed.id, connectionId: parsed.targetId }
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
}
type ReachableSshRoute<TProvider> = SshRoute<TProvider> & { provider: TProvider }
export type ReachableGitRoute = LocalGitRoute | ReachableSshRoute<SshGitProvider>
export type ReachableFilesystemRoute = LocalFilesystemRoute | ReachableSshRoute<IFilesystemProvider>
/** For work this process runs itself: `runtime:` and an unreachable SSH host throw, never run here. */
export function requireReachableGitRoute(hostId: string | null | undefined): ReachableGitRoute {
const route = resolveGitRouteForHost(hostId)
switch (route.kind) {
case 'local':
return route
case 'ssh': {
const { provider } = route
if (!provider) {
throw sshGitProviderMissingError(route.connectionId)
}
return { ...route, provider }
}
case 'runtime':
throw new ExecutionHostNotDispatchableError(route.hostId)
}
/** For work this process runs itself: `ssh:` and `runtime:` hosts throw, never run here. */
export function requireReachableGitRoute(hostId: string | null | undefined): LocalGitRoute {
return requireLocalRoute(resolveGitRouteForHost(hostId))
}
export function requireReachableFilesystemRoute(
hostId: string | null | undefined
): ReachableFilesystemRoute {
const route = resolveFilesystemRouteForHost(hostId)
): LocalFilesystemRoute {
return requireLocalRoute(resolveFilesystemRouteForHost(hostId))
}
function requireLocalRoute<TLocal extends LocalRoute>(
route: TLocal | RuntimeRoute | SshRoute
): TLocal {
switch (route.kind) {
case 'local':
return route
case 'ssh': {
const { provider } = route
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return { ...route, provider }
}
case 'ssh':
throw new SshHostNotServedError(route.connectionId)
case 'runtime':
throw new ExecutionHostNotDispatchableError(route.hostId)
}
}
/** 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 = requireReachableGitRoute(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
return route.provider
}
export function requireFilesystemProviderForHost(
hostId: string | null | undefined
): IFilesystemProvider {
const route = requireReachableFilesystemRoute(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
return route.provider
}
@@ -0,0 +1,15 @@
/**
* An `ssh:<target>` host names an SSH target, never a route: this process serves no SSH provider,
* so work routed to one fails with this error. A managed host is reached as `runtime:<env>`.
*/
export const SSH_HOST_NOT_SERVED_MESSAGE =
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
export class SshHostNotServedError extends Error {
readonly code = 'ssh_host_not_served'
constructor(readonly connectionId: string) {
super(SSH_HOST_NOT_SERVED_MESSAGE)
this.name = 'SshHostNotServedError'
}
}
+3 -63
View File
@@ -49,34 +49,13 @@ describe('probeGitRemoteIdentity', () => {
})
})
it('routes each SSH host to its own git provider', async () => {
const m4air = registerHost('m4air')
const openclaw = registerHost(
'openclaw',
'origin\tgit@gitlab.example.com:team/other.git (fetch)\n'
)
it('reports unavailable for an SSH host, even with a registered git provider', async () => {
const exec = registerHost('builder')
await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:m4air')).resolves.toEqual({
status: 'resolved',
identity: gitlabIdentity
})
await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:openclaw')).resolves.toEqual({
status: 'resolved',
identity: {
canonicalKey: 'gitlab.example.com/team/other',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/other.git'
}
})
expect(m4air).toHaveBeenCalledTimes(1)
expect(openclaw).toHaveBeenCalledTimes(1)
expect(gitExecFileAsync).not.toHaveBeenCalled()
})
it('reports unavailable when the SSH host has no connected git provider', async () => {
await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({
status: 'unavailable'
})
expect(exec).not.toHaveBeenCalled()
expect(gitExecFileAsync).not.toHaveBeenCalled()
})
@@ -101,30 +80,6 @@ describe('probeGitRemoteIdentity', () => {
})
})
it('reports unavailable when a connected SSH provider cannot reach the host', async () => {
const exec = vi.fn().mockRejectedValue(new Error('ssh: connect to host builder: down'))
registerSshGitProvider('builder', { exec } as never)
registered.push('builder')
await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({
status: 'unavailable'
})
expect(exec).toHaveBeenCalledWith(
['remote', '-v'],
'/repos/orca',
expect.objectContaining({ timeoutMs: expect.any(Number) })
)
expect(gitExecFileAsync).not.toHaveBeenCalled()
})
it('settles on no-remote for an SSH repo git answered for with no remotes', async () => {
registerHost('builder', '')
await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({
status: 'no-remote'
})
})
it('bounds the local probe with a deadline and forwards the caller signal', async () => {
vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' })
const controller = new AbortController()
@@ -143,21 +98,6 @@ describe('probeGitRemoteIdentity', () => {
expect(options.timeout).toBeGreaterThan(0)
})
it('bounds the SSH probe under the relay request timeout and forwards the caller signal', async () => {
const exec = registerHost('builder')
const controller = new AbortController()
await probeGitRemoteIdentity('/repos/orca', 'ssh:builder', { signal: controller.signal })
expect(exec).toHaveBeenCalledWith(
['remote', '-v'],
'/repos/orca',
expect.objectContaining({ timeoutMs: expect.any(Number), signal: controller.signal })
)
const relayRequestTimeoutMs = 30_000
expect(exec.mock.calls[0][2].timeoutMs).toBeLessThan(relayRequestTimeoutMs)
})
it('maps a timed-out local probe to unavailable, never no-remote', async () => {
vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('git timed out.'))
+3 -14
View File
@@ -8,9 +8,6 @@ import { resolveGitRouteForHost } from './providers/execution-host-provider-disp
// Matches the background local-git-read budget in git/git-username.ts; a probe cut off early just
// reports `unavailable` and retries on the enrichment TTL.
const LOCAL_PROBE_TIMEOUT_MS = 5000
// Why looser but still bounded: the relay adds a round trip a local spawn does not have, and the
// budget must stay under the multiplexer's 30s request timeout so this deadline is the one that fires.
const SSH_PROBE_TIMEOUT_MS = 20_000
export type GitRemoteIdentityProbeOptions = {
signal?: AbortSignal
@@ -33,19 +30,11 @@ export async function runGitProbeOnHost(
executionHostId: ExecutionHostId,
options: GitRemoteIdentityProbeOptions = {}
): Promise<{ stdout: string } | null> {
const route = resolveGitRouteForHost(executionHostId)
if (route.kind === 'runtime') {
// That environment's server runs its own git, and the SSH target on its repo row is nested in
// that server's namespace — dialing it here answers for a same-named box of ours.
// A runtime server runs its own git (its repo row's SSH target is nested in that server's
// namespace), and this process serves no `ssh:` host.
if (resolveGitRouteForHost(executionHostId).kind !== 'local') {
return null
}
if (route.kind === 'ssh') {
const result = await route.provider?.exec(args, repoPath, {
signal: options.signal,
timeoutMs: options.timeoutMs ?? SSH_PROBE_TIMEOUT_MS
})
return result ?? null
}
return gitExecFileAsync(args, {
cwd: repoPath,
timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS,
+5 -14
View File
@@ -212,25 +212,16 @@ describe('detectRepoIcon', () => {
).resolves.toMatchObject({ source: 'file', label: 'favicon.png' })
})
it('routes each SSH host to its own filesystem provider', async () => {
it('reads nothing for an SSH host, even with a registered filesystem provider', async () => {
const repoPath = await makeTempRepoDir()
// On disk on this machine, so a host-blind probe would answer with this one for both hosts.
// On disk on this machine, so a host-blind probe would answer with it.
await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64'))
registerHomepageHost('m4air', 'https://m4air.example.com')
registerHomepageHost('openclaw', 'https://openclaw.example.com')
const host = registerHomepageHost('m4air', 'https://m4air.example.com')
await expect(
detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:m4air' })
).resolves.toMatchObject({
source: 'favicon',
src: expect.stringContaining('m4air.example.com')
})
await expect(
detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:openclaw' })
).resolves.toMatchObject({
source: 'favicon',
src: expect.stringContaining('openclaw.example.com')
})
).resolves.toBeUndefined()
expect(host.stat).not.toHaveBeenCalled()
})
it('reads nothing for a runtime host even when its nested SSH target is registered here', async () => {
+4 -29
View File
@@ -13,7 +13,6 @@ import {
resolveFilesystemRouteForHost,
resolveGitRouteForHost
} from './providers/execution-host-provider-dispatch'
import type { IFilesystemProvider } from './providers/types'
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
import { detectRepoFileIcon } from './repo-icon-file-detection'
import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths'
@@ -61,26 +60,6 @@ async function detectLocalPackageHomepageIcon(repoPath: string): Promise<RepoIco
}
}
async function detectRemotePackageHomepageIcon(
repoPath: string,
fsProvider: IFilesystemProvider
): Promise<RepoIcon | null> {
try {
const packageJsonPath = joinWorktreeRelativePath(repoPath, 'package.json')
const info = await fsProvider.stat(packageJsonPath)
if (info.type !== 'file' || info.size > 128 * 1024) {
return null
}
const result = await fsProvider.readFile(packageJsonPath)
if (result.isBinary) {
return null
}
return packageHomepageIcon(JSON.parse(result.content))
} catch {
return null
}
}
/**
* The connection this client may dial to read `executionHostId`'s remotes, or `refuse` when it may
* dial none. `runtime:` is refused rather than degraded to `null`: that server runs its own git,
@@ -135,14 +114,10 @@ export async function detectRepoIcon({
return fileIcon
}
// Why the same route again: a remote repoPath with no provider, and every runtime host, must
// not be probed on the client filesystem — a same-named local path answers for the wrong repo.
const remoteProvider = route.kind === 'ssh' ? route.provider : null
const homepageIcon = remoteProvider
? await detectRemotePackageHomepageIcon(repoPath, remoteProvider)
: route.kind === 'local'
? await detectLocalPackageHomepageIcon(repoPath)
: null
// Why the same route again: a remote repoPath must not be probed on the client filesystem —
// a same-named local path answers for the wrong repo.
const homepageIcon =
route.kind === 'local' ? await detectLocalPackageHomepageIcon(repoPath) : null
if (homepageIcon) {
return homepageIcon
}
+9 -120
View File
@@ -3,20 +3,9 @@ import { tmpdir } from 'node:os'
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import type * as FsPromisesModule from 'node:fs/promises'
import { describe, expect, it, vi } from 'vitest'
import {
resolveFilesystemRouteForHost,
type ExecutionHostFilesystemRoute
} from './providers/execution-host-provider-dispatch'
import type { FileReadResult, FileStat, IFilesystemProvider } from './providers/types'
import { resolveFilesystemRouteForHost } from './providers/execution-host-provider-dispatch'
import { detectRepoFileIcon } from './repo-icon-file-detection'
function sshRoute(
connectionId: string,
provider: IFilesystemProvider | null
): ExecutionHostFilesystemRoute {
return { kind: 'ssh', hostId: `ssh:${connectionId}`, connectionId, provider }
}
// Why: the boundary assertion is "no local read happened", which needs the real
// fs entrypoints spied rather than stubbed.
vi.mock('node:fs/promises', async (importOriginal) => {
@@ -24,91 +13,17 @@ vi.mock('node:fs/promises', async (importOriginal) => {
return { ...actual, stat: vi.fn(actual.stat), readFile: vi.fn(actual.readFile) }
})
const WEBP_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA=='
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
function remoteFilesystemProvider({
stat,
readFile
}: {
stat: (filePath: string) => Promise<FileStat>
readFile: (filePath: string) => Promise<FileReadResult>
}): IFilesystemProvider {
return { stat, readFile } as IFilesystemProvider
}
describe('detectRepoFileIcon remote probing', () => {
it('detects binary WebP icons through a remote filesystem provider', async () => {
const provider = remoteFilesystemProvider({
stat: async (filePath) => {
if (!filePath.endsWith('/public/icon.webp')) {
throw new Error('ENOENT')
}
return { type: 'file', size: 34, mtime: 0 }
},
readFile: async () => ({ content: WEBP_BASE64, isBinary: true, mimeType: 'image/webp' })
})
await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toEqual({
type: 'image',
src: `data:image/webp;base64,${WEBP_BASE64}`,
source: 'file',
label: 'public/icon.webp'
})
})
it('keeps conventional-path priority when probes resolve concurrently', async () => {
const provider = remoteFilesystemProvider({
stat: async (filePath) => {
if (filePath.endsWith('/favicon.png') || filePath.endsWith('/public/favicon.png')) {
return { type: 'file', size: 8, mtime: 0 }
}
throw new Error('ENOENT')
},
readFile: async (filePath) => {
if (filePath.endsWith('/favicon.png')) {
await Promise.resolve()
}
return { content: PNG_BASE64, isBinary: true, mimeType: 'image/png' }
}
})
await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toMatchObject({
source: 'file',
label: 'favicon.png'
})
})
it('bounds concurrent remote probes when no conventional icon exists', async () => {
let activeStats = 0
let maxActiveStats = 0
const stat = vi.fn(async (): Promise<FileStat> => {
activeStats += 1
maxActiveStats = Math.max(maxActiveStats, activeStats)
await Promise.resolve()
activeStats -= 1
throw new Error('ENOENT')
})
const provider = remoteFilesystemProvider({
stat,
readFile: async () => {
throw new Error('unexpected read')
}
})
await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toBeNull()
expect(maxActiveStats).toBeGreaterThan(1)
expect(maxActiveStats).toBeLessThanOrEqual(6)
})
})
describe('detectRepoFileIcon connection boundary', () => {
it('never reads the client filesystem for a remote repo whose provider is missing', async () => {
it('never reads the client filesystem for a repo on an SSH target', async () => {
vi.mocked(stat).mockClear()
vi.mocked(readFile).mockClear()
await expect(detectRepoFileIcon('/repo', sshRoute('ssh-target-1', null))).resolves.toBeNull()
await expect(
detectRepoFileIcon('/repo', resolveFilesystemRouteForHost('ssh:ssh-target-1'))
).resolves.toBeNull()
expect(stat).not.toHaveBeenCalled()
expect(readFile).not.toHaveBeenCalled()
@@ -143,12 +58,7 @@ describe('detectRepoFileIcon connection boundary', () => {
})
describe('declared repo icons through production filesystem routes', () => {
it.each([
['local', false],
['ssh', false],
['local', true],
['ssh', true]
] as const)('preserves declared icon detection on %s (no icon: %s)', async (kind, noIcon) => {
it.each([false, true])('preserves declared icon detection (no icon: %s)', async (noIcon) => {
const directory = await mkdtemp(join(tmpdir(), 'orca-icon-href-'))
const source = noIcon
? 'a'.repeat(256 * 1024)
@@ -157,30 +67,9 @@ describe('declared repo icons through production filesystem routes', () => {
await mkdir(join(directory, 'public'))
await writeFile(join(directory, 'index.html'), source)
await writeFile(join(directory, 'public', 'chosen.png'), Buffer.from(PNG_BASE64, 'base64'))
const provider = remoteFilesystemProvider({
stat: async (path) => {
const info = await stat(path)
return {
type: info.isFile() ? 'file' : 'directory',
size: info.size,
mtime: info.mtimeMs
}
},
readFile: async (path) => {
const buffer = await readFile(path)
const isBinary = path.endsWith('.png')
return {
content: buffer.toString(isBinary ? 'base64' : 'utf8'),
isBinary,
mimeType: isBinary ? 'image/png' : 'text/html'
}
}
})
const route: ExecutionHostFilesystemRoute =
kind === 'local'
? resolveFilesystemRouteForHost('local')
: sshRoute('icon-oracle', provider)
await expect(detectRepoFileIcon(directory, route)).resolves.toEqual(
await expect(
detectRepoFileIcon(directory, resolveFilesystemRouteForHost('local'))
).resolves.toEqual(
noIcon
? null
: {
+3 -72
View File
@@ -2,7 +2,6 @@ import { readFile, stat } from 'node:fs/promises'
import { buildImageDataUri } from '../shared/image-data-uri'
import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon'
import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch'
import type { IFilesystemProvider } from './providers/types'
import { extractIconHref } from './repo-icon-source-href'
import { iconHrefCandidates } from './repo-icon-href-candidates'
import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths'
@@ -124,32 +123,11 @@ async function readLocalImageIcon(
return repoIconFromImageBuffer(buffer, relativePath)
}
async function readRemoteImageIcon(
repoPath: string,
fsProvider: IFilesystemProvider,
relativePath: string
): Promise<RepoIcon | null> {
const filePath = joinWorktreeRelativePath(repoPath, relativePath)
const info = await fsProvider.stat(filePath)
if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) {
return null
}
const result = await fsProvider.readFile(filePath)
if (!result.content) {
return null
}
// Why: detect the binary format after decoding remote file content.
const buffer = result.isBinary
? Buffer.from(result.content, 'base64')
: Buffer.from(result.content, 'utf8')
return repoIconFromImageBuffer(buffer, relativePath)
}
async function detectConventionalImageIcon(
readIcon: (relativePath: string) => Promise<RepoIcon | null>
): Promise<RepoIcon | null> {
// Why: SSH stats are network round trips; bounded batches avoid making the
// expanded candidate list serial without flooding the remote filesystem.
// Why: bounded batches keep the expanded candidate list from probing serially (slow on
// WSL/network mounts) without flooding the filesystem.
for (
let offset = 0;
offset < REPO_ICON_FILE_CANDIDATES.length;
@@ -209,48 +187,6 @@ async function detectLocalImageIcon(repoPath: string): Promise<RepoIcon | null>
return null
}
async function detectRemoteImageIcon(
repoPath: string,
fsProvider: IFilesystemProvider
): Promise<RepoIcon | null> {
const conventionalIcon = await detectConventionalImageIcon((relativePath) =>
readRemoteImageIcon(repoPath, fsProvider, relativePath)
)
if (conventionalIcon) {
return conventionalIcon
}
for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) {
try {
const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile)
const sourceInfo = await fsProvider.stat(sourcePath)
if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) {
continue
}
const result = await fsProvider.readFile(sourcePath)
if (result.isBinary) {
continue
}
const href = extractIconHref(result.content)
if (!href) {
continue
}
for (const relativePath of iconHrefCandidates(href, sourceFile)) {
try {
const icon = await readRemoteImageIcon(repoPath, fsProvider, relativePath)
if (icon) {
return icon
}
} catch {
// Try the next href resolution.
}
}
} catch {
// Try the next source file.
}
}
return null
}
/**
* Takes the resolved host route rather than a `connectionId`, because the incumbent shape spelled
* "this is local", "this host is unreachable" and "this is a runtime host" all as a falsy id — and
@@ -264,13 +200,8 @@ export function detectRepoFileIcon(
case 'local':
return detectLocalImageIcon(repoPath)
case 'ssh':
// A dropped provider fails closed: repoPath lives on the SSH host, so a same-named local
// path would hand back another repository's icon.
return route.provider
? detectRemoteImageIcon(repoPath, route.provider)
: Promise.resolve(null)
case 'runtime':
// That environment's server holds these files; this process has no route to them.
// These files live on another host; a same-named local path would answer for another repo.
return Promise.resolve(null)
}
}
+6 -3
View File
@@ -10,6 +10,7 @@ import {
import { isFolderRepo } from '../shared/repo-kind'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { areWorktreePathsEqual } from './ipc/worktree-logic'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
@@ -87,12 +88,13 @@ async function listRoutedRepoWorktrees(
// 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 (!route.provider) {
const provider = getSshGitProvider(route.connectionId)
if (!provider) {
throw new WorktreeCatalogUnavailableError(
`Worktree catalog unavailable for ${repo.path}: SSH connection "${route.connectionId}" is not connected.`
)
}
return await route.provider.listWorktrees(repo.path)
return await provider.listWorktrees(repo.path)
}
const worktrees = hasLocalRepoWorktreeListOptions(options)
? await listLocal(repo.path, options)
@@ -122,7 +124,8 @@ export async function listRepoWorktreeGraph(
return []
}
if (route.kind === 'ssh') {
return route.provider ? await route.provider.listWorktrees(repo.path) : []
const provider = getSshGitProvider(route.connectionId)
return provider ? await provider.listWorktrees(repo.path) : []
}
const worktrees = hasLocalRepoWorktreeListOptions(options)
? await listWorktreeGraph(repo.path, options)
@@ -12,6 +12,7 @@ import {
useTerminalArtifactTempFiles
} from './orca-runtime-files-terminal-artifact-fixtures'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
import { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './orca-runtime-files'
import type { RuntimeFileCommands } from './orca-runtime-files'
@@ -436,66 +437,15 @@ describe('RuntimeFileCommands', () => {
expect(writeTerminalArtifact).toHaveBeenCalled()
})
it('reads a remote non-temp artifact cited by native chat', async () => {
const artifactPath = '/home/me/report.json'
it('refuses a native-chat artifact on an SSH target as not served', async () => {
const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture(
artifactPath,
'/home/me/report.json',
true
)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).resolves.toMatchObject({ content: '{"ok":true}' })
expect(readTerminalArtifact).toHaveBeenCalledWith(
artifactPath,
expect.objectContaining({ expectedRealPath: artifactPath })
)
})
it('rejects a retargeted remote native-chat artifact grant', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact, moveArtifactTarget } =
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
moveArtifactTarget('/home/me/private.json')
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).rejects.toThrow('terminal_file_grant_stale')
expect(readTerminalArtifact).not.toHaveBeenCalled()
})
it('rejects a replaced remote native-chat artifact grant', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact, replaceArtifact } =
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
replaceArtifact()
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).rejects.toThrow('terminal_file_grant_stale')
resolveRemoteNativeChatArtifact(commands, '/home/me/report.json')
).rejects.toThrow(SshHostNotServedError)
expect(readTerminalArtifact).not.toHaveBeenCalled()
})
@@ -345,8 +345,7 @@ vi.mock('../../providers/ssh-git-dispatch', () => ({
vi.mock('../../ssh/ssh-target-registry', () => ({
getActiveMultiplexer: getActiveMultiplexerMock,
getRegisteredSshState: () => ({ remotePlatform: 'linux' }),
setSshActiveMultiplexerResolver: vi.fn()
getRegisteredSshState: () => ({ remotePlatform: 'linux' })
}))
vi.mock('../../preflight/agent-detection', () => ({
@@ -10,7 +10,10 @@ import type {
LocalFilesystemProvider,
LocalFilesystemProviderOptions
} from '../providers/local-filesystem-provider'
import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-filesystem-dispatch'
import type { IFilesystemProvider } from '../providers/types'
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
@@ -60,7 +63,11 @@ export function runtimeFileRouteForTarget(target: {
case 'local':
return { kind: 'local', createProvider: route.createProvider }
case 'ssh':
return { kind: 'ssh', connectionId: route.connectionId, provider: route.provider }
return {
kind: 'ssh',
connectionId: route.connectionId,
provider: getSshFilesystemProvider(route.connectionId) ?? null
}
case 'runtime':
throw new ExecutionHostNotDispatchableError(route.hostId)
}
@@ -8,7 +8,8 @@ import {
import type { IFilesystemProvider } from '../providers/types'
import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
getSshFilesystemProvider,
requireSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import {
requireRuntimeFileProvider,
@@ -30,8 +31,6 @@ import {
watchFileExplorerInWatcherProcess
} from './file-watcher-host'
import { registerRuntimeFileWatcherRelease } from './runtime-file-watcher-leases'
import { requireFilesystemProviderForHost } from '../providers/execution-host-provider-dispatch'
import { toSshExecutionHostId } from '../../shared/execution-host'
export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical extends RuntimeFileCommandsWithWriteTerminalArtifactFile {
protected async assertRemoteTerminalFileGrantPathStillCanonical(
@@ -40,7 +39,7 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
if (!grant.connectionId) {
throw new Error('terminal_file_grant_mismatch')
}
const provider = requireFilesystemProviderForHost(toSshExecutionHostId(grant.connectionId))
const provider = requireSshFilesystemProvider(grant.connectionId)
const canonicalPath =
grant.provenance === 'native-chat'
? await provider.realpath(grant.absolutePath)
@@ -24,11 +24,9 @@ import {
runtimeFileSshTargetId,
type ResolvedRuntimeFileTarget
} from './runtime-file-command-target'
import {
requireReachableFilesystemRoute,
requireFilesystemProviderForHost
} from '../providers/execution-host-provider-dispatch'
import { getConnectionExecutionHostId, toSshExecutionHostId } from '../../shared/execution-host'
import { requireReachableFilesystemRoute } from '../providers/execution-host-provider-dispatch'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getConnectionExecutionHostId } from '../../shared/execution-host'
export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends RuntimeFileCommandsWithResolveTerminalPath {
protected async resolveAllowedTerminalArtifactPath(args: {
@@ -46,10 +44,8 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
absolutePath: string,
connectionId?: string
): Promise<string> {
const route = requireReachableFilesystemRoute(getConnectionExecutionHostId(connectionId))
return route.kind === 'ssh'
? route.provider.realpath(absolutePath)
: canonicalPathForArtifactComparison(absolutePath)
requireReachableFilesystemRoute(getConnectionExecutionHostId(connectionId))
return canonicalPathForArtifactComparison(absolutePath)
}
protected async resolveAbsoluteFileGrant(args: {
@@ -115,7 +111,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
absolutePath: string,
connectionId: string
): Promise<string | null> {
const provider = requireFilesystemProviderForHost(toSshExecutionHostId(connectionId))
const provider = requireSshFilesystemProvider(connectionId)
const roots = ['/tmp', '/private/tmp']
const providerTempDir = await provider.getTempDir?.().catch(() => null)
if (providerTempDir) {
@@ -9,7 +9,7 @@ import {
resolveGitRouteForHost
} from '../providers/execution-host-provider-dispatch'
import type { createLocalGitProvider, LocalGitProvider } from '../providers/local-git-provider'
import { sshGitProviderMissingError } from '../providers/ssh-git-dispatch'
import { getSshGitProvider, sshGitProviderMissingError } from '../providers/ssh-git-dispatch'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue'
@@ -71,7 +71,11 @@ export function runtimeGitRouteForTarget(target: RuntimeGitTarget): RuntimeGitRo
case 'local':
return { kind: 'local', createProvider: route.createProvider }
case 'ssh':
return { kind: 'ssh', connectionId: route.connectionId, provider: route.provider }
return {
kind: 'ssh',
connectionId: route.connectionId,
provider: getSshGitProvider(route.connectionId) ?? null
}
case 'runtime':
throw new ExecutionHostNotDispatchableError(route.hostId)
}
@@ -4,6 +4,7 @@ import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id'
import type { Repo } from '../../shared/repo-types'
import type { Worktree } from '../../shared/worktree/types'
import { gitExecFileAsync } from '../git/runner'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { isENOENT } from '../ipc/filesystem-auth'
import {
getLocalWorktreePathAccess,
@@ -44,7 +45,7 @@ export async function isRuntimeWorktreePathMissing(
access.statPath
)
}
const fsProvider = route.provider
const fsProvider = getSshFilesystemProvider(route.connectionId)
return fsProvider ? isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) : false
}
@@ -757,7 +757,7 @@ describe('getHostedReviewCreationEligibility', () => {
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => {
it('resolves remote eligibility from SSH repo metadata without running git or PR copy', async () => {
const remoteGit = {
exec: vi.fn(async (args: string[]) => ({
stdout:
@@ -793,13 +793,8 @@ describe('getHostedReviewCreationEligibility', () => {
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/remote/repo', executionHostId: 'ssh:ssh-1' })
)
// Why: the base-on-remote probe must run on the SSH host that will execute
// the provider create, so it flows through the relay exec, not local git.
expect(remoteGit.exec).toHaveBeenCalledWith(['remote'], '/remote/repo')
expect(remoteGit.exec).toHaveBeenCalledWith(
['show-ref', '--verify', '--quiet', '--', 'refs/remotes/origin/main'],
'/remote/repo'
)
// An SSH host is not served here, so its base probe never runs as local git either.
expect(remoteGit.exec).not.toHaveBeenCalled()
})
it('offers push as the next action for authenticated branches with local-only commits', async () => {
@@ -3,7 +3,6 @@ import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
import type { GitUpstreamStatus } from '../../shared/git-status-types'
import { gitExecFileAsync } from '../github/gh-utils'
@@ -13,11 +12,7 @@ import { parsePorcelainV1Records, type PorcelainV1Record } from '../git/porcelai
import { resolveDefaultBaseRefViaExec } from '../git/repo'
import { getUpstreamStatus } from '../git/upstream'
import { findExistingWorktreeSymlinkPaths } from '../git/worktree-symlink-detection'
import {
ExecutionHostNotDispatchableError,
resolveGitRouteForHost,
type ExecutionHostGitRoute
} from '../providers/execution-host-provider-dispatch'
import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
getHostedReviewLocalGitOptions,
@@ -125,25 +120,8 @@ async function listSuffixRemoteBaseRefs(
/**
* Why not a `connectionId` check: `null` used to mean "local", "runtime host" and "unresolved"
* alike, so a worktree whose owner is named only by `executionHostId` had its preflight git run
* against this machine's copy of a remote path. `runtime:` is a routing mistake here rather than a
* fallback — that environment's server runs its own git.
* against this machine's copy of a remote path. `runtime:` and `ssh:` hosts throw instead.
*/
function requireHostedReviewGitRoute(executionHostId: ExecutionHostId): ExecutionHostGitRoute {
const route = resolveGitRouteForHost(executionHostId)
if (route.kind === 'runtime') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
return route
}
/** Loss of contact is not locality: an SSH host with no provider refuses, it does not run here. */
function requireHostedReviewSshProvider(route: ExecutionHostGitRoute) {
if (route.kind !== 'ssh' || !route.provider) {
throw new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.')
}
return route.provider
}
async function runGitForHostedReview(
repoPath: string,
args: string[],
@@ -151,13 +129,7 @@ async function runGitForHostedReview(
options: HostedReviewExecutionOptions = {},
commandOptions: HostedReviewGitRunOptions = {}
): Promise<{ stdout: string; stderr?: string }> {
const route = requireHostedReviewGitRoute(executionHostId)
if (route.kind === 'ssh') {
const provider = requireHostedReviewSshProvider(route)
return commandOptions.timeoutMs === undefined
? provider.exec(args, repoPath)
: provider.exec(args, repoPath, { timeoutMs: commandOptions.timeoutMs })
}
requireReachableGitRoute(executionHostId)
return gitExecFileAsync(args, {
cwd: repoPath,
...getHostedReviewLocalGitOptions(options),
@@ -267,13 +239,7 @@ export async function hasUncommittedChanges(
executionHostId: ExecutionHostId,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
const route = requireHostedReviewGitRoute(executionHostId)
if (route.kind === 'ssh') {
// Why: the relay restricts generic git.exec, so use the structured status RPC for SSH dirty checks.
// No shared-link exclusion here: remote worktree creation skips the symlink
// and shared-directory passes entirely, so a remote worktree never has one.
return (await requireHostedReviewSshProvider(route).getStatus(repoPath)).entries.length > 0
}
requireReachableGitRoute(executionHostId)
// Why: `-z` keeps paths raw so the shared-link comparison below can't be
// defeated by Git quoting a path with spaces or non-ASCII bytes.
const { stdout } = await gitExecFileAsync(['status', '--porcelain', '-z'], {
@@ -321,18 +287,6 @@ export async function getHostedReviewUpstreamStatus(
executionHostId: ExecutionHostId,
options: HostedReviewExecutionOptions = {}
): Promise<GitUpstreamStatus> {
const route = requireHostedReviewGitRoute(executionHostId)
if (route.kind !== 'ssh') {
return getUpstreamStatus(repoPath, undefined, getHostedReviewLocalGitOptions(options))
}
const provider = requireHostedReviewSshProvider(route)
try {
// Why: the relay blocks generic git.exec, so use its dedicated upstream RPC for SSH divergence.
return await provider.getUpstreamStatus(repoPath)
} catch (error) {
if (isNoUpstreamError(error)) {
return { hasUpstream: false, ahead: 0, behind: 0 }
}
throw new Error(normalizeGitErrorMessage(error, 'upstream'))
}
requireReachableGitRoute(executionHostId)
return getUpstreamStatus(repoPath, undefined, getHostedReviewLocalGitOptions(options))
}
@@ -572,81 +572,6 @@ describe('createHostedReview', () => {
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
})
it('uses the SSH git provider for remote hosted-review preflight', async () => {
const remoteGit = {
getStatus: vi.fn(async () => ({ entries: [], conflictOperation: 'unknown' })),
getUpstreamStatus: vi.fn(async () => ({
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 0
})),
exec: vi.fn(async (args: string[]) => {
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
return { stdout: 'feature\n', stderr: '' }
}
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'show-ref') {
return { stdout: 'abc refs/remotes/origin/main\n', stderr: '' }
}
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '- Feature title\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
}
getSshGitProviderMock.mockReturnValue(remoteGit)
await expect(
createHostedReview(
'/remote/repo',
{
provider: 'github',
base: 'main',
head: 'feature',
title: 'Feature'
},
'ssh:ssh-1'
)
).resolves.toEqual({
ok: true,
number: 12,
url: 'https://github.com/acme/orca/pull/12'
})
expect(remoteGit.exec).toHaveBeenCalledWith(
['rev-parse', '--abbrev-ref', 'HEAD'],
'/remote/repo'
)
expect(remoteGit.getStatus).toHaveBeenCalledWith('/remote/repo')
expect(remoteGit.exec).not.toHaveBeenCalledWith(['status', '--porcelain'], '/remote/repo')
expect(remoteGit.getUpstreamStatus).toHaveBeenCalledWith('/remote/repo')
expect(remoteGit.exec).not.toHaveBeenCalledWith(
['rev-list', '--left-right', '--count', 'HEAD...@{u}'],
'/remote/repo'
)
expect(getUpstreamStatusMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['auth', 'status', '--hostname', 'github.com'],
{ host: 'github.com' }
)
expect(createGitHubPullRequestMock).toHaveBeenCalledWith(
'/remote/repo',
{
provider: 'github',
base: 'main',
head: 'feature',
title: 'Feature'
},
'ssh:ssh-1'
)
})
it('returns the existing review instead of creating a duplicate', async () => {
getHostedReviewForBranchMock.mockResolvedValue({
provider: 'github',
@@ -165,6 +165,7 @@ import {
hostedReviewSshConnectionId
} from './hosted-review-execution-host'
import { RuntimeHostedReviewCommands } from '../runtime/runtime-hosted-review-commands'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
const REPO_PATH = '/remote/workspace/repo'
@@ -354,43 +355,17 @@ describe('hosted-review lookups route on the resolved execution host', () => {
})
describe('hosted-review creation routes on the resolved execution host', () => {
it('runs the GitLab preflight and create on the executionHostId-only SSH host', async () => {
it('refuses GitLab preflight on an executionHostId-only SSH host without running git here', async () => {
getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/widgets' })
const commands = makeCommands(makeRepo({ executionHostId: 'ssh:ssh-a' }))
const result = await commands.createHostedReview({
repoSelector: 'repo-1',
provider: 'gitlab',
...CREATE_INPUT
})
await expect(
commands.createHostedReview({ repoSelector: 'repo-1', provider: 'gitlab', ...CREATE_INPUT })
).rejects.toThrow(SshHostNotServedError)
expect(result.ok).toBe(true)
expect(sshTargetThatRanGit()).toBe('ssh-a')
expect(sshTargetThatRanGit()).toBeNull()
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(createGitLabMergeRequestMock).toHaveBeenCalledWith(
REPO_PATH,
expect.objectContaining({ provider: 'gitlab' }),
'ssh:ssh-a'
)
})
it('runs the GitHub preflight on the executionHostId host, not the connectionId one', async () => {
getRepoSlugMock.mockResolvedValue({ host: 'github.com', owner: 'a', repo: 'b' })
const commands = makeCommands(makeRepo({ connectionId: 'ssh-b', executionHostId: 'ssh:ssh-a' }))
const result = await commands.createHostedReview({
repoSelector: 'repo-1',
provider: 'github',
...CREATE_INPUT
})
expect(result.ok).toBe(true)
expect(sshTargetThatRanGit()).toBe('ssh-a')
expect(createGitHubPullRequestMock).toHaveBeenCalledWith(
REPO_PATH,
expect.objectContaining({ provider: 'github' }),
'ssh:ssh-a'
)
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
})
it('creates a Bitbucket review locally for a runtime row rather than dialing its nested target', async () => {
@@ -428,7 +403,7 @@ describe('hosted-review creation routes on the resolved execution host', () => {
})
expect(getAzureDevOpsRepoSlugMock).toHaveBeenCalledWith(REPO_PATH, 'ssh-a')
expect(sshTargetThatRanGit()).toBe('ssh-a')
expect(sshTargetThatRanGit()).toBeNull()
})
it('resolves the GitHub slug for a repo listing on the row it is asked about', async () => {
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
import { getActiveMultiplexer } from './ssh-target-registry'
describe('getActiveMultiplexer', () => {
it('refuses with the typed not-served error, not a resolver wiring error', () => {
expect(() => getActiveMultiplexer('ssh-1')).toThrow(SshHostNotServedError)
expect(() => getActiveMultiplexer('ssh-1')).toThrow(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
expect(() => getActiveMultiplexer('ssh-1')).not.toThrow(
'ssh_active_multiplexer_resolver_not_installed'
)
})
})
+4 -23
View File
@@ -1,4 +1,5 @@
import type { SshConnectionStore } from './ssh-connection-store'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import type { SshConnectionManager } from './ssh-connection-manager'
import type {
@@ -87,32 +88,12 @@ export function getRegisteredSshTargetMutations(): SshTargetMutations {
return registeredSshTargetMutations
}
let registeredGetActiveMultiplexer:
| ((connectionId: string) => SshChannelMultiplexer | undefined)
| null = null
export function setSshActiveMultiplexerResolver(
resolve: ((connectionId: string) => SshChannelMultiplexer | undefined) | null
): void {
registeredGetActiveMultiplexer = resolve
}
/**
* The live channel multiplexer for a connection, or undefined when the target is not
* connected. Undefined means "not connected", never "the connection died" — callers
* must not read absence here as an `exited` verdict (docs/reference/ssh-execution-boundary.md).
*
* Why it throws when no resolver is installed rather than returning undefined: that
* case is a wiring error, not a connection state, and the two are indistinguishable to
* callers. A host that never loaded the SSH layer would otherwise report every target as
* quietly "not connected" — which is precisely the unverifiable-reported-as-exited
* conflation the execution-boundary doc exists to prevent.
* The relay channel multiplexer is gone, so no SSH target is served here: every caller is a dead
* SSH arm and gets the typed "not served" error, never an `exited` verdict.
*/
export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined {
if (!registeredGetActiveMultiplexer) {
throw new Error('ssh_active_multiplexer_resolver_not_installed')
}
return registeredGetActiveMultiplexer(connectionId)
throw new SshHostNotServedError(connectionId)
}
let registeredGetSshConnectionManager: (() => SshConnectionManager | null) | null = null
+9 -4
View File
@@ -16,6 +16,8 @@ import {
resolveFilesystemRouteForHost,
resolveGitRouteForHost
} from './providers/execution-host-provider-dispatch'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
import { mergeWorktree } from './ipc/worktree-logic'
import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options'
@@ -102,14 +104,15 @@ async function listWorktreesForSpaceScan(
}
}
if (route.kind === 'ssh') {
if (!route.provider) {
const provider = getSshGitProvider(route.connectionId)
if (!provider) {
return {
ok: false,
status: 'unavailable',
error: `SSH connection "${route.connectionId}" is not connected.`
}
}
const worktrees = await route.provider.listWorktrees(repo.path, { signal })
const worktrees = await provider.listWorktrees(repo.path, { signal })
throwIfWorkspaceSpaceScanAborted(signal)
return { ok: true, worktrees }
}
@@ -205,6 +208,8 @@ export async function scanWorkspaceSpaceRepo(args: {
options.onProgress
)
const filesystemRoute = resolveFilesystemRouteForHost(getRepoExecutionHostId(repo))
const sshFilesystemProvider =
filesystemRoute.kind === 'ssh' ? getSshFilesystemProvider(filesystemRoute.connectionId) : null
const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => {
throwIfWorkspaceSpaceScanAborted(options.signal)
reportProgress(
@@ -217,12 +222,12 @@ export async function scanWorkspaceSpaceRepo(args: {
)
const row =
filesystemRoute.kind !== 'local'
? filesystemRoute.kind === 'ssh' && filesystemRoute.provider
? sshFilesystemProvider
? await scanRemoteWorkspaceSpaceWorktree(
repo,
worktree,
scannedAt,
filesystemRoute.provider,
sshFilesystemProvider,
limiters.remoteFallbackTraversal,
options.signal
)
@@ -39,10 +39,11 @@ import {
} from './worktree-removal-home-guard'
import {
ExecutionHostNotDispatchableError,
resolveFilesystemRouteForHost,
resolveGitRouteForHost
} from './providers/execution-host-provider-dispatch'
import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { SshHostNotServedError } from './providers/ssh-host-not-served-error'
import type { SshGitProvider } from './providers/ssh-git-provider'
import type { IFilesystemProvider } from './providers/types'
@@ -69,16 +70,16 @@ export function resolveWorktreeRemovalRoute(hostId: ExecutionHostId): WorktreeRe
case 'runtime':
throw new ExecutionHostNotDispatchableError(route.hostId)
case 'ssh': {
if (!route.provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
const provider = getSshGitProvider(route.connectionId)
if (!provider) {
throw new SshHostNotServedError(route.connectionId)
}
const fsRoute = resolveFilesystemRouteForHost(hostId)
return {
kind: 'ssh',
hostId: route.hostId,
connectionId: route.connectionId,
provider: route.provider,
fsProvider: fsRoute.kind === 'ssh' ? fsRoute.provider : null
provider,
fsProvider: getSshFilesystemProvider(route.connectionId) ?? null
}
}
}
@@ -87,7 +88,7 @@ export function resolveWorktreeRemovalRoute(hostId: ExecutionHostId): WorktreeRe
/**
* Reads the `$HOME` an active SSH session resolved on its host.
*
* Injected the same way `setSshActiveMultiplexerResolver` is, because importing
* Injected rather than imported, because importing
* the session table here would run its registration inside every suite that
* partially mocks the SSH registry. Unresolved stays `null` — "unknown", never
* "this client's home".