mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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.'))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+3
-4
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user