mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(providers): delete the SSH git and filesystem providers and relay-only wiring (#27644)
* refactor(providers): delete the SSH git and filesystem providers and relay-only wiring * refactor(ssh): drop relay-protocol exports only the deleted stream readers used --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -7,20 +7,14 @@ import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-requ
|
||||
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: () => 0
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
|
||||
}))
|
||||
@@ -32,7 +26,6 @@ describe('Azure DevOps pull request creation', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n',
|
||||
stderr: ''
|
||||
@@ -183,44 +176,6 @@ describe('Azure DevOps pull request creation', () => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves Azure DevOps remotes through the SSH git provider', async () => {
|
||||
const remoteGit = {
|
||||
exec: vi.fn(async () => ({
|
||||
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n',
|
||||
stderr: ''
|
||||
}))
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(remoteGit)
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({
|
||||
pullRequestId: 38,
|
||||
title: 'Remote Azure create',
|
||||
status: 'active',
|
||||
creationDate: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
) as never
|
||||
|
||||
await expect(
|
||||
createAzureDevOpsPullRequest(
|
||||
'/remote/repo',
|
||||
{
|
||||
provider: 'azure-devops',
|
||||
base: 'main',
|
||||
head: 'feature/azure',
|
||||
title: 'Remote Azure create'
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
number: 38
|
||||
})
|
||||
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies auth failures without retrying shell commands', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ message: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
sshExecMock: vi.fn()
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
@@ -15,19 +14,15 @@ import {
|
||||
getAzureDevOpsRepoRefForRemote,
|
||||
parseAzureDevOpsRepoRef
|
||||
} from './repository-ref'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
||||
|
||||
describe('parseAzureDevOpsRepoRef', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
sshExecMock.mockReset()
|
||||
unregisterSshGitProvider('conn-1')
|
||||
_resetAzureDevOpsRepoRefCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshGitProvider('conn-1')
|
||||
_resetAzureDevOpsRepoRefCache()
|
||||
})
|
||||
|
||||
@@ -102,28 +97,6 @@ describe('parseAzureDevOpsRepoRef', () => {
|
||||
expect(parseAzureDevOpsRepoRef('git@github.com:stablyai/orca.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves repository refs through the SSH git provider for connected repos', async () => {
|
||||
sshExecMock.mockResolvedValueOnce({
|
||||
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n',
|
||||
stderr: ''
|
||||
})
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
|
||||
host: 'dev.azure.com',
|
||||
organization: 'acme',
|
||||
project: 'Project',
|
||||
repository: 'repo',
|
||||
apiBaseUrl: 'https://dev.azure.com/acme/Project',
|
||||
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo'
|
||||
})
|
||||
|
||||
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps local host and local WSL repository-ref cache entries separate', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
@@ -165,14 +138,13 @@ describe('parseAzureDevOpsRepoRef', () => {
|
||||
})
|
||||
|
||||
it('bounds cached repository refs for distinct repo paths', async () => {
|
||||
sshExecMock.mockResolvedValue({
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n',
|
||||
stderr: ''
|
||||
})
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
for (let i = 0; i < 513; i += 1) {
|
||||
await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin', 'conn-1')
|
||||
await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin')
|
||||
}
|
||||
|
||||
expect(_getAzureDevOpsRepoRefCacheSize()).toBe(512)
|
||||
|
||||
@@ -5,20 +5,14 @@ import {
|
||||
} from './pull-request-creation'
|
||||
import { _resetBitbucketRepoRefCache } from './repository-ref'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: () => 0
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
|
||||
}))
|
||||
@@ -49,7 +43,6 @@ describe('Bitbucket pull request creation', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' }
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://bitbucket.org/team/repo.git\n',
|
||||
stderr: ''
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
sshExecMock: vi.fn()
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
@@ -12,24 +11,18 @@ vi.mock('../git/runner', () => ({
|
||||
import {
|
||||
_getBitbucketRepoRefCacheSize,
|
||||
_resetBitbucketRepoRefCache,
|
||||
getBitbucketRepoRefForRemote,
|
||||
getBitbucketRepoRef,
|
||||
parseBitbucketRepoRef
|
||||
} from './repository-ref'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
||||
|
||||
describe('Bitbucket repository refs', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
sshExecMock.mockReset()
|
||||
unregisterSshGitProvider('conn-1')
|
||||
_resetBitbucketRepoRefCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshGitProvider('conn-1')
|
||||
})
|
||||
afterEach(() => {})
|
||||
|
||||
it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => {
|
||||
expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({
|
||||
@@ -108,22 +101,4 @@ describe('Bitbucket repository refs', () => {
|
||||
|
||||
expect(_getBitbucketRepoRefCacheSize()).toBe(512)
|
||||
})
|
||||
|
||||
it('resolves project refs through the SSH git provider for connected repos', async () => {
|
||||
sshExecMock.mockResolvedValueOnce({
|
||||
stdout: 'git@bitbucket.org:remote/project.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
|
||||
workspace: 'remote',
|
||||
repoSlug: 'project'
|
||||
})
|
||||
|
||||
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
readDocPreviewFile: vi.fn(),
|
||||
requireSshFilesystemProvider: vi.fn()
|
||||
callRuntimeEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
|
||||
vi.mock('../persistence', () => ({
|
||||
getCanonicalUserDataPath: () => '/user-data'
|
||||
}))
|
||||
|
||||
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
|
||||
@@ -53,9 +50,6 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreview
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
mocks.requireSshFilesystemProvider.mockReturnValue({
|
||||
readDocPreviewFile: mocks.readDocPreviewFile
|
||||
})
|
||||
})
|
||||
|
||||
describe('docPreviewContentType', () => {
|
||||
@@ -68,131 +62,15 @@ describe('docPreviewContentType', () => {
|
||||
})
|
||||
|
||||
describe('readDocPreviewFile — ssh owner', () => {
|
||||
it('reads text through the SSH filesystem provider', async () => {
|
||||
mocks.readDocPreviewFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
|
||||
it('404s with the not-served message instead of reading anything', async () => {
|
||||
const grant = sshGrant()
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
|
||||
|
||||
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith({
|
||||
boundaryPath: '/home/alice/docs',
|
||||
entryPath: '/home/alice/docs/index.html',
|
||||
implicitRootPath: null,
|
||||
authorizedRootPaths: ['/home/alice/docs'],
|
||||
targetPath: '/home/alice/docs/index.html',
|
||||
maxTextBytes: 10 * 1024 * 1024,
|
||||
maxBinaryBytes: 10 * 1024 * 1024
|
||||
})
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a base64 binary asset', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
||||
mocks.readDocPreviewFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
|
||||
})
|
||||
|
||||
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
|
||||
// always whole and needs no truncation flag.
|
||||
it('serves a whole SSH read that carries no truncation flag', async () => {
|
||||
mocks.readDocPreviewFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// Older hosts refuse fonts without bytes or a MIME type; keep that answer explicit.
|
||||
it('reports a file type the host will not send as unsupported-asset', async () => {
|
||||
mocks.readDocPreviewFile.mockResolvedValue({ content: '', isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
|
||||
})
|
||||
|
||||
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
|
||||
// reporting it as a refused format would be a failure the workspace never reported.
|
||||
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
content: '',
|
||||
isBinary: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
|
||||
})
|
||||
|
||||
it('404s when the host cannot canonicalize the path at all', async () => {
|
||||
mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
|
||||
await expect(readDocPreviewFile(grant, 'index.html')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('404s a path outside the grant root without touching the provider', async () => {
|
||||
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires approval for a sibling directory before touching the SSH provider', async () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
requestBase: '/home/alice',
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'docs/index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: 403,
|
||||
reason: 'authorization-required'
|
||||
})
|
||||
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
|
||||
authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')
|
||||
mocks.readDocPreviewFile.mockResolvedValue({ content: 'logo', isBinary: false })
|
||||
|
||||
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true })
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
boundaryPath: '/home/alice',
|
||||
implicitRootPath: '/home/alice/docs',
|
||||
authorizedRootPaths: ['/home/alice/assets'],
|
||||
targetPath: '/home/alice/assets/logo.png'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
|
||||
mocks.readDocPreviewFile.mockRejectedValue(new Error('file_too_large'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 413
|
||||
})
|
||||
})
|
||||
|
||||
it('404s when the provider read fails', async () => {
|
||||
mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
status: 404,
|
||||
message: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
})
|
||||
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import type { RuntimeFilePreviewResult } from '../../shared/runtime-file-contrac
|
||||
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
|
||||
import { FONT_FILE_MIME_TYPES } from '../../shared/font-file-extensions'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { getCanonicalUserDataPath } from '../persistence'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
|
||||
import {
|
||||
resolveDocPreviewAuthorityPaths,
|
||||
resolveDocPreviewCandidatePath,
|
||||
@@ -16,8 +15,6 @@ import {
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
|
||||
const DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES = 10 * 1024 * 1024
|
||||
const DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/** Older hosts may refuse a binary format, so report the file type they cannot send. */
|
||||
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
|
||||
@@ -29,16 +26,12 @@ const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to
|
||||
/** The paired host rejects an over-cap asset outright instead of clamping it. */
|
||||
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
|
||||
|
||||
/** Same stance as the SSH relay message: previews fail closed on a host without scoped reads. */
|
||||
/** Previews fail closed on a host without scoped reads. */
|
||||
const RUNTIME_DOC_PREVIEW_UPDATE_REQUIRED_MESSAGE =
|
||||
'Secure document previews require a newer Orca on the paired machine. Update it and try again.'
|
||||
|
||||
/** Both owners refuse an over-cap file; only their error shapes differ. */
|
||||
function isTooLargeReadError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof FileReadCapExceededError ||
|
||||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
|
||||
)
|
||||
return error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR
|
||||
}
|
||||
|
||||
export type DocPreviewReadOutcome =
|
||||
@@ -168,23 +161,7 @@ export async function readDocPreviewFile(
|
||||
const contentType = docPreviewContentType(relativePath)
|
||||
try {
|
||||
if (grant.owner.kind === 'ssh') {
|
||||
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
|
||||
const authority = resolveDocPreviewAuthorityPaths(grant)
|
||||
if (!authority.entryPath || !provider.readDocPreviewFile) {
|
||||
return notFoundOutcome()
|
||||
}
|
||||
return toOutcome(
|
||||
await provider.readDocPreviewFile({
|
||||
boundaryPath: grant.requestBase,
|
||||
entryPath: authority.entryPath,
|
||||
implicitRootPath: authority.implicitRootPath,
|
||||
authorizedRootPaths: authority.authorizedRootPaths,
|
||||
targetPath: absolutePath,
|
||||
maxTextBytes: DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES,
|
||||
maxBinaryBytes: DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES
|
||||
}),
|
||||
contentType
|
||||
)
|
||||
return notFoundOutcome(SSH_HOST_NOT_SERVED_MESSAGE)
|
||||
}
|
||||
const runtimeOwner = grant.owner
|
||||
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
|
||||
|
||||
@@ -5,8 +5,10 @@ const mocks = vi.hoisted(() => ({
|
||||
remove: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
vi.mock('./ssh/ssh-target-registry', () => ({
|
||||
connectRegisteredSshTarget: mocks.connect
|
||||
}))
|
||||
vi.mock('./ipc/ssh', () => ({
|
||||
connectRegisteredSshTarget: mocks.connect,
|
||||
getSshConnectionStore: () => ({
|
||||
upsertRuntimeOwnedTarget: (runtimeId: string) => ({
|
||||
id: `runtime-ssh-${runtimeId}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { connectRegisteredSshTarget, getSshConnectionStore } from './ipc/ssh'
|
||||
import { getSshConnectionStore } from './ipc/ssh'
|
||||
import { connectRegisteredSshTarget } from './ssh/ssh-target-registry'
|
||||
import {
|
||||
disconnectRegisteredSshTarget,
|
||||
removeRegisteredSshTarget
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getSshGitProviderMock, getSshGitProviderGenerationMock, gitExecFileAsyncMock } = vi.hoisted(
|
||||
() => ({
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable'
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
@@ -35,9 +25,6 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
|
||||
describe('remote ref probe cache (P1-D)', () => {
|
||||
beforeEach(() => {
|
||||
getSshGitProviderMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReturnValue(0)
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000_000)
|
||||
@@ -89,27 +76,6 @@ describe('remote ref probe cache (P1-D)', () => {
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not let a lookup on a reconnected provider join the old connection probe', async () => {
|
||||
const cache = createRemoteRefProbeCache(parseExampleRef)
|
||||
let releaseStalled = (): void => {}
|
||||
const execMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise((resolve) => (releaseStalled = () => resolve({ stdout: '' })))
|
||||
)
|
||||
.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' })
|
||||
getSshGitProviderMock.mockReturnValue({ exec: execMock })
|
||||
|
||||
const stalled = cache.get('/repo', 'origin', 'conn-1')
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' })
|
||||
expect(execMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
releaseStalled()
|
||||
await expect(stalled).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not let a probe abandoned as stale overwrite its successor answer', async () => {
|
||||
const cache = createRemoteRefProbeCache(parseExampleRef)
|
||||
const stalled = deferred<{ stdout: string }>()
|
||||
@@ -143,46 +109,12 @@ describe('remote ref probe cache (P1-D)', () => {
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('never caches a miss from a runtime that could not be asked at all', async () => {
|
||||
it('answers an SSH repo with null without running git on this machine', async () => {
|
||||
const cache = createRemoteRefProbeCache(parseExampleRef)
|
||||
getSshGitProviderMock.mockReturnValueOnce(undefined)
|
||||
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
|
||||
|
||||
// The provider was gone, not the remote: reconnecting must not need the TTL.
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
exec: vi.fn(async () => ({ stdout: 'git@example.com:team/repo.git\n' }))
|
||||
})
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' })
|
||||
})
|
||||
|
||||
it('holds an SSH repo that has no such remote instead of re-asking every poll', async () => {
|
||||
const cache = createRemoteRefProbeCache(parseExampleRef)
|
||||
const exec = vi.fn(async () => {
|
||||
throw new Error("fatal: No such remote 'origin'")
|
||||
})
|
||||
getSshGitProviderMock.mockReturnValue({ exec })
|
||||
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
|
||||
expect(exec).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1)
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps re-asking an SSH repo whose probe died with its transport', async () => {
|
||||
const cache = createRemoteRefProbeCache(parseExampleRef)
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('relay request failed: connection closed'))
|
||||
.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' })
|
||||
getSshGitProviderMock.mockReturnValue({ exec })
|
||||
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
|
||||
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' })
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not cache a probe killed on its deadline as a definitive miss', async () => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe'
|
||||
import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe'
|
||||
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
|
||||
import type { GitAdmissionTier } from './command-runner/git-exec-options'
|
||||
|
||||
/**
|
||||
@@ -86,10 +84,7 @@ export function createRemoteRefProbeCache<Ref>(
|
||||
},
|
||||
remoteName
|
||||
)
|
||||
// Why: null is the SSH runtime being disconnected, not an answer about the
|
||||
// remote — and it costs no `git`, so there is nothing here to spare. It is
|
||||
// deliberately the one negative with no TTL floor: flooring it would make a
|
||||
// reconnected host wait the interval out for a probe it could serve now.
|
||||
// Why: null is an SSH repo this process cannot read, not an answer about the remote.
|
||||
if (stdout === null) {
|
||||
return null
|
||||
}
|
||||
@@ -97,16 +92,10 @@ export function createRemoteRefProbeCache<Ref>(
|
||||
publish(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
// Why: a probe killed on its deadline says nothing about the remote, and an
|
||||
// SSH failure is usually a reconnect or tunnel state rather than an answer.
|
||||
// Only "no such remote" is the repo itself saying it is not this provider's
|
||||
// — anything else cached would poison it, on SSH for the generation's life.
|
||||
// Why: a probe killed on its deadline says nothing about the remote.
|
||||
if (isTransientGitProbeError(error)) {
|
||||
return null
|
||||
}
|
||||
if (connectionId && !isStableMissingGitRemoteError(error)) {
|
||||
return null
|
||||
}
|
||||
publish(null)
|
||||
return null
|
||||
}
|
||||
@@ -114,12 +103,7 @@ export function createRemoteRefProbeCache<Ref>(
|
||||
|
||||
return {
|
||||
async get(repoPath, remoteName, connectionId, localGitOptions = {}) {
|
||||
// Why: a reconnect retires the connection an answer came from, and with it
|
||||
// the probe still running on it — stamping the generation stops a caller on
|
||||
// the new connection from adopting either.
|
||||
const runtimeKey = connectionId
|
||||
? `${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
const runtimeKey = connectionId || `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}`
|
||||
const cached = repoRefCache.get(cacheKey)
|
||||
if (cached) {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getSshGitProviderMock, gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable',
|
||||
sshGitProviderMissingError: () => new Error('SSH Git provider unavailable')
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
@@ -23,7 +16,6 @@ import {
|
||||
|
||||
describe('remote URL probe', () => {
|
||||
beforeEach(() => {
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
@@ -48,27 +40,6 @@ describe('remote URL probe', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds the SSH remote read with the same deadline as the local one', async () => {
|
||||
const exec = vi.fn(
|
||||
async (_args: string[], _cwd: string, _options?: { signal?: AbortSignal }) => ({
|
||||
stdout: 'git@github.com:acme/orca.git\n'
|
||||
})
|
||||
)
|
||||
getSshGitProviderMock.mockReturnValue({ exec })
|
||||
|
||||
await expect(
|
||||
readRemoteUrl({ repoPath: '/repo', connectionId: 'ssh-1' }, 'origin')
|
||||
).resolves.toContain('github.com')
|
||||
|
||||
const [args, cwd, options] = exec.mock.calls[0]
|
||||
expect(args).toEqual(['remote', 'get-url', 'origin'])
|
||||
expect(cwd).toBe('/repo')
|
||||
// The relay bounds each phase separately and resets on every frame, so only a
|
||||
// signal spans the whole round trip.
|
||||
expect(options?.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(options?.signal?.aborted).toBe(false)
|
||||
})
|
||||
|
||||
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'
|
||||
@@ -93,8 +64,6 @@ describe('remote URL probe', () => {
|
||||
})
|
||||
|
||||
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.toThrow(SshHostNotServedError)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
|
||||
import type { GitAdmissionTier } from './command-runner/git-exec-options'
|
||||
@@ -13,9 +12,7 @@ import { getConnectionExecutionHostId } from '../../shared/execution-host'
|
||||
* wedged host — a dead network mount or stalled WSL interop. Unbounded, the call
|
||||
* never returns and every caller above it hangs with it. Passing a timeout is
|
||||
* also what arms the runner's kill path; Node's own waits forever on a child
|
||||
* that ignores signals. The SSH branch spends the same budget as one deadline
|
||||
* over the whole round trip: the relay's own bounds are per-phase and restart on
|
||||
* every frame, so a relay dribbling output outlives them.
|
||||
* that ignores signals.
|
||||
*/
|
||||
export const REMOTE_URL_PROBE_TIMEOUT_MS = 30_000
|
||||
|
||||
@@ -26,20 +23,13 @@ export type RemoteUrlProbeContext = {
|
||||
admissionTier?: GitAdmissionTier
|
||||
}
|
||||
|
||||
/** Reads a remote URL, or null when the repo's SSH runtime is not connected. */
|
||||
/** Reads a remote URL, or null for an SSH repo, which this process does not serve. */
|
||||
export async function readRemoteUrl(
|
||||
context: RemoteUrlProbeContext,
|
||||
remoteName: string
|
||||
): Promise<string | null> {
|
||||
if (context.connectionId) {
|
||||
const provider = getSshGitProvider(context.connectionId)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath, {
|
||||
signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS)
|
||||
})
|
||||
return stdout
|
||||
return null
|
||||
}
|
||||
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
|
||||
cwd: context.repoPath,
|
||||
|
||||
@@ -6,10 +6,6 @@ vi.mock('../git/runner', () => ({
|
||||
stderr: ''
|
||||
}))
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSSHGitProvider: vi.fn(() => undefined),
|
||||
getSSHGitProviderGeneration: () => 0
|
||||
}))
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
readHostedPullRequestTemplate: vi.fn(async () => '')
|
||||
}))
|
||||
|
||||
@@ -2,20 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGiteaPullRequest, isGiteaReviewCreationAuthenticated } from './pull-request-creation'
|
||||
import { _resetGiteaRepoRefCache } from './repository-ref'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: () => 0
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
|
||||
}))
|
||||
@@ -27,7 +21,6 @@ describe('Gitea pull request creation', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'gitea-token' }
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://git.example.com/code/team/repo.git\n',
|
||||
stderr: ''
|
||||
@@ -102,46 +95,6 @@ describe('Gitea pull request creation', () => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves Gitea remotes through the SSH git provider', async () => {
|
||||
const remoteGit = {
|
||||
exec: vi.fn(async () => ({
|
||||
stdout: 'git@git.example.com:code/team/repo.git\n',
|
||||
stderr: ''
|
||||
}))
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(remoteGit)
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({
|
||||
number: 14,
|
||||
title: 'Remote Gitea create',
|
||||
state: 'open',
|
||||
html_url: 'https://git.example.com/code/team/repo/pulls/14',
|
||||
updated_at: '2026-06-01T00:00:00Z',
|
||||
mergeable: true
|
||||
})
|
||||
) as never
|
||||
|
||||
await expect(
|
||||
createGiteaPullRequest(
|
||||
'/remote/repo',
|
||||
{
|
||||
provider: 'gitea',
|
||||
base: 'main',
|
||||
head: 'feature/gitea',
|
||||
title: 'Remote Gitea create'
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
number: 14
|
||||
})
|
||||
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies validation failures from the REST API', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ message: 'Validation failed' }, { status: 422 })
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
sshExecMock: vi.fn()
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
@@ -13,23 +12,17 @@ import {
|
||||
_getGiteaRepoRefCacheSize,
|
||||
_resetGiteaRepoRefCache,
|
||||
getGiteaRepoRef,
|
||||
getGiteaRepoRefForRemote,
|
||||
parseGiteaRepoRef
|
||||
} from './repository-ref'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
||||
|
||||
describe('Gitea repository ref parsing', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
sshExecMock.mockReset()
|
||||
unregisterSshGitProvider('conn-1')
|
||||
_resetGiteaRepoRefCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshGitProvider('conn-1')
|
||||
})
|
||||
afterEach(() => {})
|
||||
|
||||
it('parses HTTPS remotes and derives the API base URL', () => {
|
||||
expect(parseGiteaRepoRef('https://git.example.com/team/project.git')).toEqual({
|
||||
@@ -139,23 +132,4 @@ describe('Gitea repository ref parsing', () => {
|
||||
|
||||
expect(_getGiteaRepoRefCacheSize()).toBe(512)
|
||||
})
|
||||
|
||||
it('resolves repository refs through the SSH git provider for connected repos', async () => {
|
||||
sshExecMock.mockResolvedValueOnce({
|
||||
stdout: 'git@gitea.example.test:remote/project.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({
|
||||
host: 'gitea.example.test',
|
||||
owner: 'remote',
|
||||
repo: 'project'
|
||||
})
|
||||
|
||||
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
@@ -8,8 +7,7 @@ const {
|
||||
getEnterpriseGitHubRepoSlugMock,
|
||||
extractExecErrorMock,
|
||||
acquireMock,
|
||||
releaseMock,
|
||||
getSshFilesystemProviderMock
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
@@ -23,12 +21,7 @@ const {
|
||||
}
|
||||
}),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn(),
|
||||
getSshFilesystemProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', () => ({
|
||||
@@ -88,7 +81,6 @@ describe('createGitHubPullRequest', () => {
|
||||
extractExecErrorMock.mockClear()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
getSshFilesystemProviderMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
@@ -354,76 +346,6 @@ describe('createGitHubPullRequest', () => {
|
||||
expect(options).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['.github/pull_request_template.md', ['.github/pull_request_template.md']],
|
||||
[
|
||||
'.github/PULL_REQUEST_TEMPLATE.md',
|
||||
['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md']
|
||||
],
|
||||
[
|
||||
'docs/PULL_REQUEST_TEMPLATE.md',
|
||||
[
|
||||
'.github/pull_request_template.md',
|
||||
'.github/PULL_REQUEST_TEMPLATE.md',
|
||||
'pull_request_template.md',
|
||||
'PULL_REQUEST_TEMPLATE.md',
|
||||
'docs/pull_request_template.md',
|
||||
'docs/PULL_REQUEST_TEMPLATE.md'
|
||||
]
|
||||
]
|
||||
] as [string, string[]][])(
|
||||
'reads PR templates from the SSH filesystem provider at %s',
|
||||
async (relativeTemplatePath, expectedRelativeLookups) => {
|
||||
const templateBody = `Remote template body from ${relativeTemplatePath}`
|
||||
const readRemoteFile = vi.fn(async (path: string) => {
|
||||
if (path === `/remote/repo-root/${relativeTemplatePath}`) {
|
||||
return {
|
||||
content: templateBody,
|
||||
isBinary: false
|
||||
}
|
||||
}
|
||||
throw new Error('missing template')
|
||||
})
|
||||
getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
const writtenBodies: string[] = []
|
||||
ghExecFileAsyncMock.mockImplementationOnce(async (args: string[]) => {
|
||||
const bodyPath = args[args.indexOf('--body-file') + 1]
|
||||
writtenBodies.push(await readFile(bodyPath, 'utf8'))
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: 46,
|
||||
url: 'https://github.com/acme/widgets/pull/46'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
createGitHubPullRequest(
|
||||
'/remote/repo-root',
|
||||
{
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/ssh-template',
|
||||
title: 'SSH Template PR',
|
||||
body: '',
|
||||
useTemplate: true
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 46,
|
||||
url: 'https://github.com/acme/widgets/pull/46'
|
||||
})
|
||||
|
||||
expect(readRemoteFile.mock.calls.map(([path]) => path)).toEqual(
|
||||
expectedRelativeLookups.map((relativeLookup) => `/remote/repo-root/${relativeLookup}`)
|
||||
)
|
||||
expect(writtenBodies).toEqual([templateBody])
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back to parsing the PR URL for older gh output', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -84,11 +84,6 @@ vi.mock('./github-enterprise-repository', () => ({
|
||||
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProviderGeneration: vi.fn(() => 0),
|
||||
getSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./conflict-summary', () => ({
|
||||
getPRConflictSummary: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -28,7 +28,6 @@ vi.mock('./gh-utils', () => ({
|
||||
vi.mock('./client/lookup/branch-lookup-resolution', () => ({
|
||||
resolvePRForBranchOutcome: mocks.resolve
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 }))
|
||||
import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
|
||||
|
||||
const outcome: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 1 }
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
@@ -34,7 +33,6 @@ const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
gitExecFileAsyncMock,
|
||||
getSshGitProviderMock,
|
||||
resolvePRRepositoryCandidatesMock
|
||||
} = clientMocks
|
||||
|
||||
@@ -123,7 +121,6 @@ describe('getPRForBranch SSH execution boundary', () => {
|
||||
})
|
||||
|
||||
it('does not rev-parse HEAD locally when the SSH provider is unregistered', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
mockMergedBranchPRLookup()
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' })
|
||||
|
||||
@@ -171,7 +168,6 @@ describe('getPRForBranch SSH execution boundary', () => {
|
||||
})
|
||||
|
||||
it('does not read tracked upstreams locally when the SSH provider is unregistered', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
mockUpstreamOnlyPRLookup()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`,
|
||||
@@ -186,9 +182,6 @@ describe('getPRForBranch SSH execution boundary', () => {
|
||||
})
|
||||
|
||||
it('reports an upstream error when the SSH HEAD probe loses its provider mid-flight', async () => {
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
|
||||
})
|
||||
mockMergedBranchPRLookup()
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
@@ -198,9 +191,6 @@ describe('getPRForBranch SSH execution boundary', () => {
|
||||
})
|
||||
|
||||
it('reports an upstream error when the SSH upstream probe loses its provider mid-flight', async () => {
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
|
||||
})
|
||||
mockUpstreamOnlyPRLookup()
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -41,7 +41,6 @@ export function resetPRForBranchMocks(mocks: GitHubClientMocks): void {
|
||||
mocks.noteRateLimitSpendMock.mockReset()
|
||||
mocks.ghRepoExecOptionsMock.mockClear()
|
||||
mocks.githubRepoContextMock.mockClear()
|
||||
mocks.getSshGitProviderMock.mockReset()
|
||||
mocks.readLocalGitConfigSignatureMock.mockReset()
|
||||
mocks.readLocalGitConfigSignatureMock.mockResolvedValue(undefined)
|
||||
mocks.acquireMock.mockReset()
|
||||
|
||||
@@ -23,7 +23,6 @@ export type GitHubClientMocks = {
|
||||
noteRateLimitSpendMock: Mock
|
||||
ghRepoExecOptionsMock: Mock<typeof ghRepoExecOptions>
|
||||
githubRepoContextMock: Mock<typeof githubRepoContext>
|
||||
getSshGitProviderMock: Mock
|
||||
readLocalGitConfigSignatureMock: Mock
|
||||
acquireMock: Mock
|
||||
releaseMock: Mock
|
||||
@@ -62,7 +61,6 @@ export function createGitHubClientMocks(): GitHubClientMocks {
|
||||
...localGitOptions
|
||||
})
|
||||
),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
readLocalGitConfigSignatureMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
@@ -131,22 +129,10 @@ export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMo
|
||||
}
|
||||
}
|
||||
|
||||
export type SshGitDispatchModuleMock = {
|
||||
getSshGitProviderGeneration: Mock
|
||||
getSshGitProvider: Mock
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: string
|
||||
export type LocalGitConfigSignatureModuleMock = {
|
||||
readLocalGitConfigSignature: Mock
|
||||
}
|
||||
|
||||
export function sshGitDispatchModuleMock(mocks: GitHubClientMocks): SshGitDispatchModuleMock {
|
||||
return {
|
||||
getSshGitProviderGeneration: vi.fn(() => 0),
|
||||
getSshGitProvider: mocks.getSshGitProviderMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.'
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalGitConfigSignatureModuleMock = { readLocalGitConfigSignature: Mock }
|
||||
|
||||
export function localGitConfigSignatureModuleMock(
|
||||
mocks: GitHubClientMocks
|
||||
): LocalGitConfigSignatureModuleMock {
|
||||
|
||||
@@ -10,7 +10,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe'
|
||||
import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch'
|
||||
import { githubReadExecutionScope } from '../../github-read-execution-scope'
|
||||
import type { PRCheckDetail } from '../../../../shared/github/check-types'
|
||||
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
|
||||
@@ -253,7 +252,6 @@ export function getPRChecks(
|
||||
prRepo ?? null,
|
||||
Boolean(options?.noCache),
|
||||
connectionId ?? null,
|
||||
connectionId ? getSshGitProviderGeneration(connectionId) : null,
|
||||
githubReadExecutionScope(localGitOptions)
|
||||
])
|
||||
return runCoalescedProbe(checksReads, key, () =>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch'
|
||||
import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths'
|
||||
import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
|
||||
import {
|
||||
getHostedReviewLocalGitOptions,
|
||||
@@ -60,21 +58,11 @@ export async function readPullRequestTemplate(
|
||||
'docs/pull_request_template.md',
|
||||
'docs/PULL_REQUEST_TEMPLATE.md'
|
||||
]
|
||||
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
|
||||
if (connectionId && !remoteProvider) {
|
||||
if (connectionId) {
|
||||
return ''
|
||||
}
|
||||
for (const relativeCandidate of relativeCandidates) {
|
||||
try {
|
||||
if (remoteProvider) {
|
||||
const result = await remoteProvider.readFile(
|
||||
joinWorktreeRelativePath(repoPath, relativeCandidate)
|
||||
)
|
||||
if (result.isBinary) {
|
||||
continue
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
return await readFile(join(repoPath, relativeCandidate), 'utf8')
|
||||
} catch {
|
||||
// Try the next conventional PR template path.
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
scopeGeneration
|
||||
} from '../../../source-control/hosted-review-scope-generations'
|
||||
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe'
|
||||
import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch'
|
||||
import { githubReadExecutionScope } from '../../github-read-execution-scope'
|
||||
import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types'
|
||||
import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
|
||||
@@ -35,7 +34,6 @@ export async function getPRForBranchOutcome(
|
||||
|
||||
const key = JSON.stringify([
|
||||
executionScope,
|
||||
connectionId ? getSshGitProviderGeneration(connectionId) : null,
|
||||
repoPath,
|
||||
scopeGeneration(hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId }))),
|
||||
branchName,
|
||||
|
||||
@@ -35,7 +35,6 @@ const {
|
||||
noteRepositoryRateLimitSpendMock,
|
||||
ghRepoExecOptionsMock,
|
||||
githubRepoContextMock,
|
||||
getSshGitProviderMock,
|
||||
readLocalGitConfigSignatureMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
@@ -67,7 +66,6 @@ const {
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
})),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
readLocalGitConfigSignatureMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
@@ -107,10 +105,6 @@ vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('./local-git-config-signature', () => ({
|
||||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
@@ -221,7 +215,6 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open
|
||||
noteRateLimitSpendMock.mockReset()
|
||||
ghRepoExecOptionsMock.mockClear()
|
||||
githubRepoContextMock.mockClear()
|
||||
getSshGitProviderMock.mockReset()
|
||||
readLocalGitConfigSignatureMock.mockReset()
|
||||
readLocalGitConfigSignatureMock.mockResolvedValue(undefined)
|
||||
acquireMock.mockReset()
|
||||
|
||||
@@ -3,24 +3,15 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted(
|
||||
() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
})
|
||||
)
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
ghExecFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
import { _resetRemoteNameListingCache } from '../git/remote-name-listing'
|
||||
import {
|
||||
_getOwnerRepoCacheSize,
|
||||
@@ -73,9 +64,6 @@ function gitRemoteListCalls(): unknown[][] {
|
||||
describe('github owner/repo resolution', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReturnValue(0)
|
||||
getSshGitProviderMock.mockReset()
|
||||
_resetOwnerRepoCache()
|
||||
_resetRemoteNameListingCache()
|
||||
__resetLocalGitConfigSignatureCacheForTests()
|
||||
@@ -200,52 +188,6 @@ describe('github owner/repo resolution', () => {
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resolves SSH repo remotes through the registered SSH git provider', async () => {
|
||||
const sshProvider = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'remote' && args[1] !== 'get-url') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[2] === 'upstream') {
|
||||
throw new Error("fatal: No such remote 'upstream'")
|
||||
}
|
||||
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
|
||||
})
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider)
|
||||
|
||||
await expect(getOwnerRepo('/home/user/orca', 'openclaw-2')).resolves.toEqual({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(getSshGitProviderMock).toHaveBeenCalledWith('openclaw-2')
|
||||
expect(sshProvider.exec).toHaveBeenCalledWith(
|
||||
['remote', 'get-url', 'origin'],
|
||||
'/home/user/orca',
|
||||
{
|
||||
signal: expect.any(AbortSignal)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps local and SSH owner/repo cache entries separate for the same path', async () => {
|
||||
const sshProvider = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'remote' && args[1] !== 'get-url') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
return { stdout: 'git@github.com:remote/orca.git\n', stderr: '' }
|
||||
})
|
||||
}
|
||||
mockGitRemoteCommands({ origin: 'git@github.com:local/orca.git\n' })
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider)
|
||||
|
||||
await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'local', repo: 'orca' })
|
||||
await expect(getOwnerRepo('/repo', 'ssh-1')).resolves.toEqual({ owner: 'remote', repo: 'orca' })
|
||||
})
|
||||
|
||||
it('keeps local host and local WSL owner/repo cache entries separate for the same path', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation(
|
||||
async (args: string[], options: { wslDistro?: string } = {}) => {
|
||||
@@ -747,7 +689,6 @@ describe('github owner/repo resolution', () => {
|
||||
describe('resolveIssueSource', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
_resetOwnerRepoCache()
|
||||
_resetRemoteNameListingCache()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LocalGitExecOptions } from './github-repository-identity'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
|
||||
export function githubApiRepositoryProbeCacheKey(
|
||||
repoPath: string,
|
||||
@@ -9,7 +8,7 @@ export function githubApiRepositoryProbeCacheKey(
|
||||
requireVerifiedSshProbe: boolean
|
||||
): string {
|
||||
const runtimeKey = connectionId
|
||||
? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
||||
? `ssh:${connectionId}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
return `${runtimeKey}\0${repoPath}\0${remoteName}\0${requireVerifiedSshProbe ? 'verified' : 'tolerant'}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as SshGitDispatch from '../providers/ssh-git-dispatch'
|
||||
import type * as GitHubEnterpriseRepository from './github-enterprise-repository'
|
||||
import type * as GhUtils from './gh-utils'
|
||||
|
||||
@@ -7,23 +6,16 @@ const {
|
||||
getEnterpriseGitHubRepoSlugMock,
|
||||
getOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
getSshGitProviderGenerationMock,
|
||||
isGitHubHostAuthenticatedMock,
|
||||
shouldProbeGitRemoteMock
|
||||
} = vi.hoisted(() => ({
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
isGitHubHostAuthenticatedMock: vi.fn(),
|
||||
shouldProbeGitRemoteMock: vi.fn(async () => true)
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof SshGitDispatch>()),
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GhUtils>()),
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
@@ -57,7 +49,6 @@ beforeEach(() => {
|
||||
getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null)
|
||||
getOwnerRepoMock.mockReset().mockResolvedValue(null)
|
||||
getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null)
|
||||
getSshGitProviderGenerationMock.mockReset().mockReturnValue(0)
|
||||
isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false)
|
||||
shouldProbeGitRemoteMock.mockReset().mockResolvedValue(true)
|
||||
})
|
||||
@@ -204,23 +195,6 @@ describe('resolveGitHubRepoExecution', () => {
|
||||
})
|
||||
|
||||
describe('origin repository cache', () => {
|
||||
it('isolates Enterprise identity across SSH provider generations', async () => {
|
||||
const beforeReconnect = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }
|
||||
const afterReconnect = { owner: 'acme', repo: 'other', host: 'github.acme-corp.com' }
|
||||
getEnterpriseGitHubRepoSlugMock
|
||||
.mockResolvedValueOnce(beforeReconnect)
|
||||
.mockResolvedValueOnce(afterReconnect)
|
||||
|
||||
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
|
||||
beforeReconnect
|
||||
)
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
|
||||
afterReconnect
|
||||
)
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps an indeterminate auth inventory unverifiable during candidate discovery', async () => {
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(undefined)
|
||||
|
||||
@@ -280,83 +254,6 @@ describe('origin repository cache', () => {
|
||||
await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull()
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not reuse a cached answer after the SSH provider reconnects', async () => {
|
||||
const oldRepository = {
|
||||
owner: 'old-owner',
|
||||
repo: 'widgets',
|
||||
host: 'github.acme-corp.com'
|
||||
}
|
||||
const newRepository = {
|
||||
owner: 'new-owner',
|
||||
repo: 'widgets',
|
||||
host: 'github.acme-corp.com'
|
||||
}
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
getEnterpriseGitHubRepoSlugMock
|
||||
.mockResolvedValueOnce(oldRepository)
|
||||
.mockResolvedValueOnce(newRepository)
|
||||
|
||||
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository)
|
||||
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository)
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
getSshGitProviderGenerationMock.mockReturnValue(2)
|
||||
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository)
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse a cached negative after the SSH provider reconnects', async () => {
|
||||
const repository = {
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
host: 'github.acme-corp.com'
|
||||
}
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(repository)
|
||||
|
||||
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull()
|
||||
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull()
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
getSshGitProviderGenerationMock.mockReturnValue(2)
|
||||
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(repository)
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('isolates an in-flight probe from an older SSH provider generation', async () => {
|
||||
const oldRepository = {
|
||||
owner: 'old-owner',
|
||||
repo: 'widgets',
|
||||
host: 'github.acme-corp.com'
|
||||
}
|
||||
const newRepository = {
|
||||
owner: 'new-owner',
|
||||
repo: 'widgets',
|
||||
host: 'github.acme-corp.com'
|
||||
}
|
||||
let resolveOldProbe: (repository: typeof oldRepository) => void = () => {}
|
||||
const oldProbeResult = new Promise<typeof oldRepository>((resolve) => {
|
||||
resolveOldProbe = resolve
|
||||
})
|
||||
getEnterpriseGitHubRepoSlugMock
|
||||
.mockImplementationOnce(() => oldProbeResult)
|
||||
.mockResolvedValueOnce(newRepository)
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
|
||||
const oldProbe = getOriginGitHubApiRepository('/repo', 'ssh-1')
|
||||
await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
getSshGitProviderGenerationMock.mockReturnValue(2)
|
||||
const newProbe = getOriginGitHubApiRepository('/repo', 'ssh-1')
|
||||
await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2))
|
||||
await expect(newProbe).resolves.toEqual(newRepository)
|
||||
|
||||
resolveOldProbe(oldRepository)
|
||||
await expect(oldProbe).resolves.toEqual(oldRepository)
|
||||
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository)
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skip missing upstream remote probes', () => {
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
isGitHubHostAuthenticatedForGlobalCli
|
||||
} from './github-enterprise-repository'
|
||||
import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
function mockOriginRemote(url: string): void {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
@@ -437,74 +436,6 @@ describe('isGitHubHostAuthenticated', () => {
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse a cached answer after the SSH provider reconnects', async () => {
|
||||
const connectionId = 'ssh-reconnected-auth-cache'
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)',
|
||||
stderr: ''
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
try {
|
||||
await expect(
|
||||
isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
|
||||
).resolves.toBe(true)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
await expect(
|
||||
isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
|
||||
).resolves.toBe(false)
|
||||
} finally {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
}
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not join an in-flight auth probe from an older SSH provider generation', async () => {
|
||||
const connectionId = 'ssh-reconnected-auth-in-flight'
|
||||
let finishOldProbe: (() => void) | undefined
|
||||
ghExecFileAsyncMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishOldProbe = () =>
|
||||
resolve({
|
||||
stdout:
|
||||
'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)',
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
try {
|
||||
const oldProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
const newProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
|
||||
|
||||
await expect(newProbe).resolves.toBe(false)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
finishOldProbe?.()
|
||||
await expect(oldProbe).resolves.toBe(true)
|
||||
} finally {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
// The probe answers differ by runtime: a local repo gets a cwd, a
|
||||
// connection-backed repo does not. Sharing one entry attributes whichever
|
||||
// resolved first to both hosts.
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from './github-remote-identity-parsing'
|
||||
import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error'
|
||||
import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch'
|
||||
import { getConnectionExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -46,14 +45,13 @@ const hostAuthInFlight = new Map<string, Promise<string | null | undefined>>()
|
||||
// repos, but the answer still describes that connection's runtime (it is probed
|
||||
// without the repository cwd). Keying it per connection stops a local repo and
|
||||
// an SSH-hosted repo at the same path from adopting each other's auth state.
|
||||
// Provider generation fences stale answers when reconnect replaces the runtime.
|
||||
function runtimeCacheKey(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
wslDistro?: string
|
||||
): string {
|
||||
if (connectionId) {
|
||||
return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
||||
return `connection:${connectionId}`
|
||||
}
|
||||
const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro
|
||||
return `local:${resolvedDistro?.toLowerCase() ?? 'host'}`
|
||||
|
||||
@@ -20,10 +20,6 @@ vi.mock('../git/runner', async (importOriginal) => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./local-git-config-signature', () => ({
|
||||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
|
||||
@@ -13,12 +13,6 @@ vi.mock('../git/runner', async (importOriginal) => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: () => null,
|
||||
getSshGitProviderGeneration: () => 0,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'ssh git provider unavailable'
|
||||
}))
|
||||
|
||||
vi.mock('./local-git-config-signature', () => ({
|
||||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
|
||||
@@ -3,15 +3,11 @@ import type * as GitRunner from '../git/runner'
|
||||
|
||||
const {
|
||||
commandExecFileAsyncMock,
|
||||
getSshGitProviderGenerationMock,
|
||||
getSshGitProviderMock,
|
||||
gitExecFileAsyncMock,
|
||||
resolveWithSshGMock,
|
||||
readLocalGitConfigSignatureMock
|
||||
} = vi.hoisted(() => ({
|
||||
commandExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
resolveWithSshGMock: vi.fn(),
|
||||
readLocalGitConfigSignatureMock: vi.fn(async () => 'sig-10284')
|
||||
@@ -23,13 +19,6 @@ vi.mock('../git/runner', async (importOriginal) => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.',
|
||||
sshGitProviderMissingError: () => new Error('Remote connection dropped.')
|
||||
}))
|
||||
|
||||
vi.mock('./local-git-config-signature', () => ({
|
||||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
@@ -73,29 +62,10 @@ function mockRemoteUrl(url: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function sshProvider(hostname: string, remoteUrl = 'git@github-work:team/orca.git') {
|
||||
return {
|
||||
exec: vi.fn().mockResolvedValue({
|
||||
stdout: `${remoteUrl}\n`,
|
||||
stderr: ''
|
||||
}),
|
||||
execNonInteractive: vi.fn().mockResolvedValue({
|
||||
stdout: `hostname ${hostname}\nport 22\n`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
canceled: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOwnerRepoCache()
|
||||
_resetSshHostnameResolutionCache()
|
||||
commandExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReturnValue(0)
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
resolveWithSshGMock.mockReset()
|
||||
readLocalGitConfigSignatureMock.mockClear()
|
||||
@@ -158,27 +128,7 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => {
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves aliases inside the repository SSH runtime', async () => {
|
||||
const provider = sshProvider('github.com')
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getSshGitProviderGenerationMock.mockReturnValue(4)
|
||||
|
||||
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(provider.execNonInteractive).toHaveBeenCalledWith(
|
||||
'ssh',
|
||||
['-G', '--', 'github-work'],
|
||||
'/remote/repo',
|
||||
5_000
|
||||
)
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not read an SSH repoPath locally when its provider is missing', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
|
||||
await expect(
|
||||
getOwnerRepoForRemote(
|
||||
'/remote/repo',
|
||||
@@ -213,43 +163,6 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => {
|
||||
expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('invalidates alias resolution when an SSH provider reconnects', async () => {
|
||||
const context = { repoPath: '/remote/repo', connectionId: 'ssh-1' }
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider('github.com'))
|
||||
|
||||
await expect(
|
||||
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context)
|
||||
).resolves.toEqual({ owner: 'team', repo: 'orca' })
|
||||
|
||||
getSshGitProviderGenerationMock.mockReturnValue(2)
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider('gitlab.com'))
|
||||
await expect(
|
||||
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates owner/repo identity when an SSH provider reconnects', async () => {
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
getSshGitProviderMock.mockReturnValue(
|
||||
sshProvider('github.com', 'git@github-work:team/orca.git')
|
||||
)
|
||||
|
||||
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
|
||||
getSshGitProviderGenerationMock.mockReturnValue(2)
|
||||
getSshGitProviderMock.mockReturnValue(
|
||||
sshProvider('github.com', 'git@github-work:acme/widgets.git')
|
||||
)
|
||||
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
|
||||
owner: 'acme',
|
||||
repo: 'widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call ssh -G for literal github.com remotes', async () => {
|
||||
mockRemoteUrl('git@github.com:team/orca.git')
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe'
|
||||
import { readRemoteUrl } from '../git/remote-url-probe'
|
||||
import type { GhAccountBinding } from '../../shared/github/account-binding'
|
||||
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { readLocalGitConfigSignature } from './local-git-config-signature'
|
||||
import {
|
||||
parseGitHubOwnerRepo,
|
||||
@@ -138,7 +137,7 @@ export async function getOwnerRepoForRemote(
|
||||
requireReachableGitRoute(getConnectionExecutionHostId(context.connectionId))
|
||||
}
|
||||
const runtimeKey = context.connectionId
|
||||
? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}`
|
||||
? `ssh:${context.connectionId}`
|
||||
: `local:${context.wslDistro ?? 'host'}`
|
||||
const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}`
|
||||
const now = Date.now()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
import { commandExecFileAsync } from '../git/runner'
|
||||
import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { resolveWithSshG } from '../ssh/ssh-g-config-resolution'
|
||||
import {
|
||||
@@ -58,8 +57,7 @@ function pruneSshHostnameCache(now: number): void {
|
||||
|
||||
function sshRuntimeCacheKey(context: SshConfigResolutionContext): string {
|
||||
if (context.connectionId) {
|
||||
const generation = getSshGitProviderGeneration(context.connectionId)
|
||||
return `ssh:${context.connectionId}:${generation}`
|
||||
return `ssh:${context.connectionId}`
|
||||
}
|
||||
const distro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro
|
||||
return `local:${distro?.toLowerCase() ?? 'host'}`
|
||||
@@ -80,23 +78,7 @@ async function resolveSshHostnameInRuntime(
|
||||
context: SshConfigResolutionContext
|
||||
): Promise<string | null> {
|
||||
if (context.connectionId) {
|
||||
const provider = getSshGitProvider(context.connectionId)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await provider.execNonInteractive(
|
||||
'ssh',
|
||||
['-G', '--', host],
|
||||
context.repoPath,
|
||||
SSH_G_TIMEOUT_MS
|
||||
)
|
||||
return result.exitCode === 0 && !result.timedOut && !result.canceled
|
||||
? parseSshGHostname(result.stdout)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const wslDistro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
@@ -26,7 +25,7 @@ describe('fetchPrHeadTrackingRef', () => {
|
||||
})
|
||||
|
||||
it('fetches into the remote-tracking ref with real git for local repos', async () => {
|
||||
await fetchPrHeadTrackingRef({ path: '/repo', connectionId: null }, null, 'origin', 'feature/x')
|
||||
await fetchPrHeadTrackingRef({ path: '/repo', connectionId: null }, 'origin', 'feature/x')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['fetch', 'origin', '+refs/heads/feature/x:refs/remotes/origin/feature/x'],
|
||||
@@ -34,28 +33,9 @@ describe('fetchPrHeadTrackingRef', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the SSH tracking-ref RPC for connected repos and never runs git directly', async () => {
|
||||
const fetchRemoteTrackingRef = vi.fn(async () => {})
|
||||
|
||||
await fetchPrHeadTrackingRef(
|
||||
{ path: '/repo', connectionId: 'conn-1' },
|
||||
{ fetchRemoteTrackingRef } as unknown as SshGitProvider,
|
||||
'origin',
|
||||
'feature/x'
|
||||
)
|
||||
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
'origin',
|
||||
'feature/x',
|
||||
'refs/remotes/origin/feature/x'
|
||||
)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when a connected repo has no available SSH provider', async () => {
|
||||
it('throws for an SSH repo, which this process does not serve', async () => {
|
||||
await expect(
|
||||
fetchPrHeadTrackingRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 'feature/x')
|
||||
fetchPrHeadTrackingRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 'feature/x')
|
||||
).rejects.toThrow('SSH Git provider is not available')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -63,7 +43,6 @@ describe('fetchPrHeadTrackingRef', () => {
|
||||
it('fetches a GitHub pull head into its remote-scoped Orca ref for local repos', async () => {
|
||||
const localRef = await fetchGitHubPullRequestHeadRef(
|
||||
{ path: '/repo', connectionId: null },
|
||||
null,
|
||||
'origin',
|
||||
42
|
||||
)
|
||||
@@ -86,7 +65,7 @@ describe('fetchPrHeadTrackingRef', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42)
|
||||
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42)
|
||||
).rejects.toThrow('Remote "origin" is not configured.')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['fetch']),
|
||||
@@ -95,7 +74,7 @@ describe('fetchPrHeadTrackingRef', () => {
|
||||
})
|
||||
|
||||
it('keeps WSL routing while bounding the pull-head fetch', async () => {
|
||||
await fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42, {
|
||||
await fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42, {
|
||||
localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
@@ -109,40 +88,19 @@ describe('fetchPrHeadTrackingRef', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the SSH GitHub pull-head RPC and never runs git directly', async () => {
|
||||
const expectedRef = `refs/orca/pull/${ORIGIN_COMPONENT}/42`
|
||||
const fetchGitHubPullRequestHead = vi.fn(async () => expectedRef)
|
||||
|
||||
const localRef = await fetchGitHubPullRequestHeadRef(
|
||||
{ path: '/repo', connectionId: 'conn-1' },
|
||||
{ fetchGitHubPullRequestHead } as unknown as SshGitProvider,
|
||||
'origin',
|
||||
42
|
||||
)
|
||||
|
||||
expect(fetchGitHubPullRequestHead).toHaveBeenCalledWith('/repo', 'origin', 42)
|
||||
expect(localRef).toBe(expectedRef)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a connected GitHub pull-head fetch without an SSH provider', async () => {
|
||||
it('rejects a GitHub pull-head fetch for an SSH repo', async () => {
|
||||
await expect(
|
||||
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 42)
|
||||
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 42)
|
||||
).rejects.toThrow('SSH Git provider is not available')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid PR numbers and option-shaped remotes before running git', async () => {
|
||||
await expect(
|
||||
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 4.2)
|
||||
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 4.2)
|
||||
).rejects.toThrow('Invalid pull request number')
|
||||
await expect(
|
||||
fetchGitHubPullRequestHeadRef(
|
||||
{ path: '/repo', connectionId: null },
|
||||
null,
|
||||
'--upload-pack=x',
|
||||
42
|
||||
)
|
||||
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, '--upload-pack=x', 42)
|
||||
).rejects.toThrow('must not start with "-"')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -6,39 +6,33 @@ import {
|
||||
REVIEW_HEAD_FETCH_TIMEOUT_MS
|
||||
} from '../../shared/review-head-tracking-ref'
|
||||
import { getReviewHeadRemoteComponent } from '../git/review-head-remote-identity'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import {
|
||||
SSH_GIT_RECONNECT_MESSAGE,
|
||||
SshHostNotServedError
|
||||
} from '../providers/ssh-host-not-served-error'
|
||||
|
||||
type LocalGitExecOptions = {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
// Why: the relay's read-only git.exec channel rejects `fetch`, so SSH repos
|
||||
// must use the dedicated git.fetchRemoteTrackingRef RPC.
|
||||
export async function fetchPrHeadTrackingRef(
|
||||
repo: { path: string; connectionId?: string | null },
|
||||
sshGitProvider: SshGitProvider | null | undefined,
|
||||
remote: string,
|
||||
branch: string,
|
||||
options: { localGitExecOptions?: LocalGitExecOptions } = {}
|
||||
): Promise<void> {
|
||||
const ref = `refs/remotes/${remote}/${branch}`
|
||||
if (!repo.connectionId) {
|
||||
await gitExecFileAsync(
|
||||
['fetch', remote, `+refs/heads/${branch}:${ref}`],
|
||||
options.localGitExecOptions ?? { cwd: repo.path }
|
||||
)
|
||||
return
|
||||
if (repo.connectionId) {
|
||||
throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE)
|
||||
}
|
||||
if (!sshGitProvider) {
|
||||
throw new Error('SSH Git provider is not available. Reconnect to this target and try again.')
|
||||
}
|
||||
await sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref)
|
||||
await gitExecFileAsync(
|
||||
['fetch', remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`],
|
||||
options.localGitExecOptions ?? { cwd: repo.path }
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchGitHubPullRequestHeadRef(
|
||||
repo: { path: string; connectionId?: string | null },
|
||||
sshGitProvider: SshGitProvider | null | undefined,
|
||||
remote: string,
|
||||
prNumber: number,
|
||||
options: { localGitExecOptions?: LocalGitExecOptions } = {}
|
||||
@@ -49,22 +43,16 @@ export async function fetchGitHubPullRequestHeadRef(
|
||||
if (!isSafeReviewHeadFetchRemote(remote)) {
|
||||
throw new Error('Pull request fetch remote must not start with "-".')
|
||||
}
|
||||
if (!repo.connectionId) {
|
||||
const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path }
|
||||
const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions)
|
||||
// Why: return the same path the fetch wrote so callers don't re-resolve identity.
|
||||
const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber)
|
||||
await gitExecFileAsync(
|
||||
['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`],
|
||||
{
|
||||
...localGitExecOptions,
|
||||
timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
return localRef
|
||||
if (repo.connectionId) {
|
||||
throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE)
|
||||
}
|
||||
if (!sshGitProvider) {
|
||||
throw new Error('SSH Git provider is not available. Reconnect to this target and try again.')
|
||||
}
|
||||
return sshGitProvider.fetchGitHubPullRequestHead(repo.path, remote, prNumber)
|
||||
const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path }
|
||||
const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions)
|
||||
// Why: return the same path the fetch wrote so callers don't re-resolve identity.
|
||||
const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber)
|
||||
await gitExecFileAsync(
|
||||
['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`],
|
||||
{ ...localGitExecOptions, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
|
||||
)
|
||||
return localRef
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ vi.mock('./client/lookup/pr-number-lookup', () => ({
|
||||
vi.mock('./client/lookup/branch-lookup-resolution', () => ({
|
||||
resolvePRForBranchOutcome: mocks.resolve
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 }))
|
||||
vi.mock('../ipc/github-work-item-mutation-events', () => ({
|
||||
broadcastGitHubWorkItemMutation: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
rememberGlabKnownHost,
|
||||
rememberGlabKnownHosts
|
||||
} from './gitlab-known-host-probe'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
describe('getGlabKnownHosts', () => {
|
||||
// A locally-keyed probe must never answer from the default WSL distro.
|
||||
@@ -280,24 +279,4 @@ describe('getGlabKnownHosts', () => {
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse a successful result after an SSH provider reconnects', async () => {
|
||||
const connectionId = 'conn-reconnected'
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' })
|
||||
|
||||
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'old-tunnel.test'
|
||||
])
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'new-tunnel.test'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
unregisterSshGitProvider(connectionId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe'
|
||||
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
|
||||
import { glabExecFileAsync } from '../git/runner'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser'
|
||||
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
|
||||
|
||||
@@ -13,10 +12,7 @@ export type LocalGitExecOptions = {
|
||||
const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000
|
||||
const UNAUTHENTICATED_HOSTS_MAX_ENTRIES = 128
|
||||
export const KNOWN_HOSTS_CACHE_MAX_ENTRIES = 128
|
||||
const knownHostsCacheByExecutionContext = new Map<
|
||||
string,
|
||||
{ key: string; hosts: readonly string[] }
|
||||
>()
|
||||
const knownHostsCacheByExecutionContext = new Map<string, readonly string[]>()
|
||||
const knownHostsInFlightByExecutionContext: CoalescedProbes<readonly string[]> = new Map()
|
||||
const unauthenticatedHostExpiries = new Map<string, number>()
|
||||
|
||||
@@ -25,25 +21,11 @@ function knownHostsExecutionKey(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): string {
|
||||
if (connectionId) {
|
||||
// Why: reconnecting can replace the SSH/relay execution host under the same id.
|
||||
return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
||||
return `connection:${connectionId}`
|
||||
}
|
||||
return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native'
|
||||
}
|
||||
|
||||
function knownHostsCacheContext(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): { key: string; cacheKey: string } {
|
||||
const key = knownHostsExecutionKey(connectionId, localGitOptions)
|
||||
const cacheKey = connectionId ? `connection:${connectionId}` : key
|
||||
const cached = knownHostsCacheByExecutionContext.get(cacheKey)
|
||||
if (cached && cached.key !== key) {
|
||||
knownHostsCacheByExecutionContext.delete(cacheKey)
|
||||
}
|
||||
return { key, cacheKey }
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetKnownHostsCache(): void {
|
||||
knownHostsCacheByExecutionContext.clear()
|
||||
@@ -135,8 +117,8 @@ export function rememberGlabKnownHosts(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): void {
|
||||
const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions)
|
||||
const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts ?? DEFAULT_GITLAB_HOSTS
|
||||
const key = knownHostsExecutionKey(connectionId, localGitOptions)
|
||||
const cached = knownHostsCacheByExecutionContext.get(key) ?? DEFAULT_GITLAB_HOSTS
|
||||
const seen = new Set(cached.map(normalizeGitLabHost))
|
||||
const additions: string[] = []
|
||||
for (const host of hosts) {
|
||||
@@ -153,7 +135,7 @@ export function rememberGlabKnownHosts(
|
||||
if (additions.length === 0) {
|
||||
return
|
||||
}
|
||||
knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: [...cached, ...additions] })
|
||||
knownHostsCacheByExecutionContext.set(key, [...cached, ...additions])
|
||||
trimKnownHostsCache()
|
||||
}
|
||||
|
||||
@@ -161,33 +143,29 @@ export async function getGlabKnownHosts(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<readonly string[]> {
|
||||
const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions)
|
||||
const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts
|
||||
const key = knownHostsExecutionKey(connectionId, localGitOptions)
|
||||
const cached = knownHostsCacheByExecutionContext.get(key)
|
||||
if (cached) {
|
||||
const entry = knownHostsCacheByExecutionContext.get(cacheKey)
|
||||
if (entry) {
|
||||
knownHostsCacheByExecutionContext.delete(cacheKey)
|
||||
knownHostsCacheByExecutionContext.set(cacheKey, entry)
|
||||
}
|
||||
knownHostsCacheByExecutionContext.delete(key)
|
||||
knownHostsCacheByExecutionContext.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
// Why: only join a probe still young enough to answer, so a wedged one cannot
|
||||
// pin every later retry for the life of the process (P1-D).
|
||||
return runCoalescedProbe(knownHostsInFlightByExecutionContext, key, (ownsKey) =>
|
||||
probeGlabKnownHosts(key, cacheKey, ownsKey, connectionId, localGitOptions)
|
||||
probeGlabKnownHosts(key, ownsKey, connectionId, localGitOptions)
|
||||
)
|
||||
}
|
||||
|
||||
async function probeGlabKnownHosts(
|
||||
key: string,
|
||||
cacheKey: string,
|
||||
ownsKey: () => boolean,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<readonly string[]> {
|
||||
try {
|
||||
// Why: auth config belongs to the executing host; do not share native, WSL,
|
||||
// or reconnected SSH/relay results, and bound an otherwise global probe.
|
||||
// Why: auth config belongs to the executing host; do not share native, WSL or
|
||||
// connection results, and bound an otherwise global probe.
|
||||
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], {
|
||||
timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS,
|
||||
// Why: a local probe would cache the default distro's auth under 'native'
|
||||
@@ -200,18 +178,16 @@ async function probeGlabKnownHosts(
|
||||
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
|
||||
})
|
||||
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
|
||||
const cached = knownHostsCacheByExecutionContext.get(cacheKey)
|
||||
const remembered = cached?.key === key ? cached.hosts : []
|
||||
const remembered = knownHostsCacheByExecutionContext.get(key) ?? []
|
||||
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...remembered, ...hosts]))
|
||||
if (ownsKey() && knownHostsExecutionKey(connectionId, localGitOptions) === key) {
|
||||
knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: merged })
|
||||
if (ownsKey()) {
|
||||
knownHostsCacheByExecutionContext.set(key, merged)
|
||||
trimKnownHostsCache()
|
||||
}
|
||||
return merged
|
||||
} catch {
|
||||
// Keep failures uncached so auth or tunnel recovery is discovered later.
|
||||
const cached = knownHostsCacheByExecutionContext.get(cacheKey)
|
||||
return cached?.key === key ? cached.hosts : [...DEFAULT_GITLAB_HOSTS]
|
||||
return knownHostsCacheByExecutionContext.get(key) ?? [...DEFAULT_GITLAB_HOSTS]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execute, generations } = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
generations: new Map<string, number>()
|
||||
}))
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }))
|
||||
vi.mock('../git/runner', () => ({ glabExecFileAsync: execute }))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProviderGeneration: (connectionId: string) => generations.get(connectionId) ?? 0
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetKnownHostsCache,
|
||||
getGlabKnownHosts,
|
||||
rememberGlabKnownHost
|
||||
} from './gitlab-known-host-probe'
|
||||
import { _resetKnownHostsCache, getGlabKnownHosts } from './gitlab-known-host-probe'
|
||||
import { PROBE_COALESCE_STALE_MS } from '../git/coalesced-probe'
|
||||
|
||||
const response = (host: string) => ({ stdout: `Logged in to ${host} as user`, stderr: '' })
|
||||
const deferred = () => Promise.withResolvers<ReturnType<typeof response>>()
|
||||
|
||||
async function collect(): Promise<void> {
|
||||
if (typeof globalThis.gc !== 'function') {
|
||||
throw new Error('Run with the repository Vitest --expose-gc config')
|
||||
}
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
globalThis.gc()
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function weakResult(connectionId: string): Promise<WeakRef<readonly string[]>> {
|
||||
return new WeakRef(await getGlabKnownHosts(connectionId))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetKnownHostsCache()
|
||||
generations.clear()
|
||||
execute.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -44,48 +18,6 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('releases successful host arrays from superseded SSH generations', async () => {
|
||||
const results: WeakRef<readonly string[]>[] = []
|
||||
for (let generation = 1; generation <= 32; generation++) {
|
||||
generations.set('connection', generation)
|
||||
execute.mockResolvedValue(response(`host${generation}.test`))
|
||||
results.push(await weakResult('connection'))
|
||||
}
|
||||
await collect()
|
||||
expect(results.filter((result) => result.deref() !== undefined)).toHaveLength(1)
|
||||
await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'host32.test'])
|
||||
expect(execute).toHaveBeenCalledTimes(32)
|
||||
})
|
||||
|
||||
it('retires remembered generations without requiring an auth-status probe', async () => {
|
||||
const results: WeakRef<readonly string[]>[] = []
|
||||
for (let generation = 1; generation <= 16; generation++) {
|
||||
generations.set('connection', generation)
|
||||
rememberGlabKnownHost(`host${generation}.test`, 'connection')
|
||||
results.push(await weakResult('connection'))
|
||||
}
|
||||
await collect()
|
||||
expect(results.filter((result) => result.deref() !== undefined)).toHaveLength(1)
|
||||
await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'host16.test'])
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retain a delayed old-generation result after a replacement answers', async () => {
|
||||
const old = deferred()
|
||||
generations.set('connection', 1)
|
||||
execute.mockReturnValueOnce(old.promise)
|
||||
const oldResult = weakResult('connection')
|
||||
generations.set('connection', 2)
|
||||
execute.mockResolvedValueOnce(response('replacement.test'))
|
||||
await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test'])
|
||||
old.resolve(response('retired.test'))
|
||||
const reference = await oldResult
|
||||
await collect()
|
||||
expect(reference.deref()).toBeUndefined()
|
||||
await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test'])
|
||||
expect(execute).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not repopulate an explicitly reset cache from an earlier probe', async () => {
|
||||
const old = deferred()
|
||||
execute.mockReturnValueOnce(old.promise)
|
||||
@@ -133,39 +65,3 @@ it('prevents an abandoned same-generation probe from publishing over its success
|
||||
await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test'])
|
||||
expect(execute).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps native, WSL and other connection caches when one generation changes', async () => {
|
||||
execute
|
||||
.mockResolvedValueOnce(response('native.test'))
|
||||
.mockResolvedValueOnce(response('ubuntu.test'))
|
||||
.mockResolvedValueOnce(response('debian.test'))
|
||||
.mockResolvedValueOnce(response('connection-a.test'))
|
||||
.mockResolvedValueOnce(response('connection-b.test'))
|
||||
const native = await getGlabKnownHosts()
|
||||
const ubuntu = await getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
|
||||
const debian = await getGlabKnownHosts(undefined, { wslDistro: 'Debian' })
|
||||
await getGlabKnownHosts('connection-a')
|
||||
const other = await getGlabKnownHosts('connection-b')
|
||||
generations.set('connection-a', 1)
|
||||
rememberGlabKnownHost('replacement.test', 'connection-a')
|
||||
await expect(getGlabKnownHosts('connection-a')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'replacement.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts()).resolves.toBe(native)
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toBe(ubuntu)
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Debian' })).resolves.toBe(debian)
|
||||
await expect(getGlabKnownHosts('connection-b')).resolves.toBe(other)
|
||||
expect(execute).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('does not serve a retired generation after the current probe fails', async () => {
|
||||
execute.mockResolvedValueOnce(response('retired.test'))
|
||||
await getGlabKnownHosts('connection')
|
||||
generations.set('connection', 1)
|
||||
execute.mockRejectedValueOnce(new Error('current host unavailable'))
|
||||
await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com'])
|
||||
execute.mockResolvedValueOnce(response('current.test'))
|
||||
await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'current.test'])
|
||||
expect(execute).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { shouldProbeGitRemote } from '../git/remote-name-listing'
|
||||
import { addWslEnvKeys } from '../wsl-env'
|
||||
import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe'
|
||||
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight'
|
||||
import {
|
||||
@@ -75,13 +74,9 @@ export async function getProjectRefForRemote(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ProjectRef | null> {
|
||||
// Why: a reconnect replaces the host an answer came from under the same id, so
|
||||
// the generation is part of the signature; `knownHosts` carries the glab auth
|
||||
// state, so logging into a self-hosted instance re-asks rather than reusing a
|
||||
// ref resolved while that host was unknown.
|
||||
const runtimeKey = connectionId
|
||||
? `${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
// Why: `knownHosts` carries the glab auth state, so logging into a self-hosted
|
||||
// instance re-asks rather than reusing a ref resolved while that host was unknown.
|
||||
const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
|
||||
const cached = projectRefCache.get(cacheKey)
|
||||
if (cached) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
|
||||
const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
sshExecMock: vi.fn()
|
||||
glabExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
resolveIssueSource
|
||||
} from './gl-utils'
|
||||
import { GlabNonListResponseError } from './glab-api-response'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { _resetRemoteNameListingCache } from '../git/remote-name-listing'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
||||
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
|
||||
@@ -71,15 +69,12 @@ describe('gitlab project ref resolution', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
sshExecMock.mockReset()
|
||||
unregisterSshGitProvider('conn-1')
|
||||
_resetProjectRefCache()
|
||||
_resetRemoteNameListingCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
unregisterSshGitProvider('conn-1')
|
||||
})
|
||||
|
||||
it('keeps getProjectRef origin-based', async () => {
|
||||
@@ -215,21 +210,6 @@ describe('gitlab project ref resolution', () => {
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resolves project refs through the SSH git provider for connected repos', async () => {
|
||||
sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'remote/orca'
|
||||
})
|
||||
|
||||
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds cached project refs for distinct repo paths', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'git@gitlab.com:stablyai/orca.git\n',
|
||||
@@ -243,34 +223,6 @@ describe('gitlab project ref resolution', () => {
|
||||
expect(_getProjectRefCacheSize()).toBe(512)
|
||||
})
|
||||
|
||||
it('does not cache a missing SSH provider as a permanent null project ref', async () => {
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
|
||||
|
||||
sshExecMock.mockResolvedValueOnce({
|
||||
stdout: 'git@gitlab.com:remote/orca.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'remote/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cache transient SSH exec failures as permanent null project refs', async () => {
|
||||
sshExecMock
|
||||
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
|
||||
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'remote/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cache a local probe killed on its deadline as a definitive miss', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('git timed out.'))
|
||||
@@ -352,28 +304,6 @@ describe('gitlab project ref resolution', () => {
|
||||
}
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not serve a project ref resolved on a retired SSH connection', async () => {
|
||||
sshExecMock
|
||||
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:before/orca.git\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:after/orca.git\n', stderr: '' })
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'before/orca'
|
||||
})
|
||||
|
||||
// A reconnect can swap the execution host under the same connection id.
|
||||
unregisterSshGitProvider('conn-1')
|
||||
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
|
||||
|
||||
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'after/orca'
|
||||
})
|
||||
expect(sshExecMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GitLab operation admission', () => {
|
||||
|
||||
@@ -7,8 +7,7 @@ const {
|
||||
glabHostEnvOptionsMock,
|
||||
glabRepoExecOptionsMock,
|
||||
acquireMock,
|
||||
releaseMock,
|
||||
getSshFilesystemProviderMock
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
getProjectSlugMock: vi.fn(),
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
@@ -20,8 +19,7 @@ const {
|
||||
connectionId ? {} : { cwd: repoPath }
|
||||
),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn(),
|
||||
getSshFilesystemProviderMock: vi.fn()
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
@@ -37,10 +35,6 @@ vi.mock('./gl-utils', () => ({
|
||||
glabRepoExecOptions: glabRepoExecOptionsMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock
|
||||
}))
|
||||
|
||||
import { createGitLabMergeRequest } from './merge-request-creation'
|
||||
|
||||
describe('createGitLabMergeRequest', () => {
|
||||
@@ -52,7 +46,6 @@ describe('createGitLabMergeRequest', () => {
|
||||
glabRepoExecOptionsMock.mockClear()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
getSshFilesystemProviderMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/widgets' })
|
||||
})
|
||||
@@ -193,45 +186,6 @@ describe('createGitLabMergeRequest', () => {
|
||||
expect(options).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it('reads merge request templates from the SSH filesystem provider', async () => {
|
||||
const readRemoteFile = vi.fn(async (path: string) => {
|
||||
if (path === '/remote/repo-root/.gitlab/merge_request_templates/Default.md') {
|
||||
return { content: 'Remote MR template body', isBinary: false }
|
||||
}
|
||||
throw new Error('missing template')
|
||||
})
|
||||
getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'https://gitlab.com/acme/widgets/-/merge_requests/46\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(
|
||||
createGitLabMergeRequest(
|
||||
'/remote/repo-root',
|
||||
{
|
||||
provider: 'gitlab',
|
||||
base: 'main',
|
||||
head: 'feature/ssh-template',
|
||||
title: 'SSH Template MR',
|
||||
body: '',
|
||||
useTemplate: true
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 46,
|
||||
url: 'https://gitlab.com/acme/widgets/-/merge_requests/46'
|
||||
})
|
||||
|
||||
const [args] = glabExecFileAsyncMock.mock.calls[0]
|
||||
expect(readRemoteFile).toHaveBeenCalledWith(
|
||||
'/remote/repo-root/.gitlab/merge_request_templates/Default.md'
|
||||
)
|
||||
expect(args[args.indexOf('--description') + 1]).toBe('Remote MR template body')
|
||||
})
|
||||
|
||||
it('returns an existing merge request when GitLab reports a duplicate', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('merge request already exists'))
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from '../../shared/hosted-review-refs'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
|
||||
import {
|
||||
getHostedReviewLocalGitOptions,
|
||||
hasHostedReviewLocalGitOptions,
|
||||
@@ -100,21 +98,11 @@ async function readMergeRequestTemplate(
|
||||
'.gitlab/merge_request_template.md',
|
||||
'.gitlab/MERGE_REQUEST_TEMPLATE.md'
|
||||
]
|
||||
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
|
||||
if (connectionId && !remoteProvider) {
|
||||
if (connectionId) {
|
||||
return ''
|
||||
}
|
||||
for (const relativeCandidate of relativeCandidates) {
|
||||
try {
|
||||
if (remoteProvider) {
|
||||
const result = await remoteProvider.readFile(
|
||||
joinWorktreeRelativePath(repoPath, relativeCandidate)
|
||||
)
|
||||
if (result.isBinary) {
|
||||
continue
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
return await readFile(join(repoPath, relativeCandidate), 'utf8')
|
||||
} catch {
|
||||
// Try the next conventional GitLab merge-request template path.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
@@ -28,7 +27,6 @@ describe('fetchGitLabMergeRequestHeadRef', () => {
|
||||
it('fetches a GitLab MR head into its remote-scoped Orca ref for local repos', async () => {
|
||||
const localRef = await fetchGitLabMergeRequestHeadRef(
|
||||
{ path: '/repo', connectionId: null },
|
||||
null,
|
||||
'origin',
|
||||
42
|
||||
)
|
||||
@@ -56,7 +54,7 @@ describe('fetchGitLabMergeRequestHeadRef', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42)
|
||||
fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42)
|
||||
).rejects.toThrow('Remote "origin" is not configured.')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['fetch']),
|
||||
@@ -65,15 +63,9 @@ describe('fetchGitLabMergeRequestHeadRef', () => {
|
||||
})
|
||||
|
||||
it('keeps WSL routing while bounding the MR-head fetch', async () => {
|
||||
await fetchGitLabMergeRequestHeadRef(
|
||||
{ path: '/repo', connectionId: null },
|
||||
null,
|
||||
'origin',
|
||||
42,
|
||||
{
|
||||
localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' }
|
||||
}
|
||||
)
|
||||
await fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42, {
|
||||
localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
|
||||
cwd: '/repo',
|
||||
@@ -90,40 +82,19 @@ describe('fetchGitLabMergeRequestHeadRef', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the SSH GitLab MR-head RPC and never runs git directly', async () => {
|
||||
const expectedRef = `refs/orca/merge-requests/${ORIGIN_COMPONENT}/77`
|
||||
const fetchGitLabMergeRequestHead = vi.fn(async () => expectedRef)
|
||||
|
||||
const localRef = await fetchGitLabMergeRequestHeadRef(
|
||||
{ path: '/repo', connectionId: 'conn-1' },
|
||||
{ fetchGitLabMergeRequestHead } as unknown as SshGitProvider,
|
||||
'origin',
|
||||
77
|
||||
)
|
||||
|
||||
expect(fetchGitLabMergeRequestHead).toHaveBeenCalledWith('/repo', 'origin', 77)
|
||||
expect(localRef).toBe(expectedRef)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a connected GitLab MR-head fetch without an SSH provider', async () => {
|
||||
it('rejects a GitLab MR-head fetch for an SSH repo', async () => {
|
||||
await expect(
|
||||
fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 77)
|
||||
fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 77)
|
||||
).rejects.toThrow('SSH Git provider is not available')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid MR iids and option-shaped remotes before running git', async () => {
|
||||
await expect(
|
||||
fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 0)
|
||||
fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 0)
|
||||
).rejects.toThrow('Invalid merge request iid')
|
||||
await expect(
|
||||
fetchGitLabMergeRequestHeadRef(
|
||||
{ path: '/repo', connectionId: null },
|
||||
null,
|
||||
'--upload-pack=x',
|
||||
42
|
||||
)
|
||||
fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, '--upload-pack=x', 42)
|
||||
).rejects.toThrow('must not start with "-"')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -6,20 +6,19 @@ import {
|
||||
} from '../../shared/review-head-tracking-ref'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { getReviewHeadRemoteComponent } from '../git/review-head-remote-identity'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import {
|
||||
SSH_GIT_RECONNECT_MESSAGE,
|
||||
SshHostNotServedError
|
||||
} from '../providers/ssh-host-not-served-error'
|
||||
|
||||
type LocalGitExecOptions = {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
// Why: the relay's read-only git.exec channel rejects `fetch`, so SSH repos
|
||||
// must use the dedicated git.fetchGitLabMergeRequestHeadRef RPC. Mirrors
|
||||
// fetchGitHubPullRequestHeadRef so both providers pin the durable head ref
|
||||
// the same way.
|
||||
// Why: mirrors fetchGitHubPullRequestHeadRef so both forges pin the durable head ref the same way.
|
||||
export async function fetchGitLabMergeRequestHeadRef(
|
||||
repo: { path: string; connectionId?: string | null },
|
||||
sshGitProvider: SshGitProvider | null | undefined,
|
||||
remote: string,
|
||||
mrIid: number,
|
||||
options: { localGitExecOptions?: LocalGitExecOptions } = {}
|
||||
@@ -30,22 +29,16 @@ export async function fetchGitLabMergeRequestHeadRef(
|
||||
if (!isSafeReviewHeadFetchRemote(remote)) {
|
||||
throw new Error('Merge request fetch remote must not start with "-".')
|
||||
}
|
||||
if (!repo.connectionId) {
|
||||
const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path }
|
||||
const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions)
|
||||
// Why: return the same path the fetch wrote so callers don't re-resolve identity.
|
||||
const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mrIid)
|
||||
await gitExecFileAsync(
|
||||
['fetch', '--no-tags', remote, `+refs/merge-requests/${mrIid}/head:${localRef}`],
|
||||
{
|
||||
...localGitExecOptions,
|
||||
timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
return localRef
|
||||
if (repo.connectionId) {
|
||||
throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE)
|
||||
}
|
||||
if (!sshGitProvider) {
|
||||
throw new Error('SSH Git provider is not available. Reconnect to this target and try again.')
|
||||
}
|
||||
return sshGitProvider.fetchGitLabMergeRequestHead(repo.path, remote, mrIid)
|
||||
const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path }
|
||||
const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions)
|
||||
// Why: return the same path the fetch wrote so callers don't re-resolve identity.
|
||||
const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mrIid)
|
||||
await gitExecFileAsync(
|
||||
['fetch', '--no-tags', remote, `+refs/merge-requests/${mrIid}/head:${localRef}`],
|
||||
{ ...localGitExecOptions, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
|
||||
)
|
||||
return localRef
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -39,10 +35,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
|
||||
@@ -24,10 +24,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -41,10 +37,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
|
||||
@@ -20,10 +20,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -37,10 +33,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
REPO_PATH,
|
||||
WORKTREE_FEATURE_PATH,
|
||||
detectConflictOperationMock,
|
||||
getSshGitProviderMock,
|
||||
resetFilesystemIpcMocks
|
||||
} from './filesystem-test-harness'
|
||||
|
||||
@@ -21,10 +20,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -38,10 +33,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock('./local-worktree-runtime-options', () => ({
|
||||
getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock,
|
||||
getLocalGitOptionsForRepo: vi.fn(() => ({})),
|
||||
@@ -92,7 +83,6 @@ describe('git:conflictOperation ssh routing', () => {
|
||||
})
|
||||
|
||||
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(
|
||||
|
||||
@@ -4,10 +4,6 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type * as RepoWorktrees from '../repo-worktrees'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
|
||||
|
||||
type Handler = (event: unknown, args: unknown) => Promise<unknown>
|
||||
@@ -302,11 +298,9 @@ describe('a project file opened by its full path', () => {
|
||||
})
|
||||
|
||||
describe('an SSH rename', () => {
|
||||
it('goes to the remote host as before, whatever access it declares', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
try {
|
||||
await call('fs:rename', {
|
||||
it('is refused without touching the local document, whatever access it declares', async () => {
|
||||
await expect(
|
||||
call('fs:rename', {
|
||||
oldPath: note,
|
||||
newPath: join(base, 'note.md'),
|
||||
access: documentFolder(note),
|
||||
@@ -314,11 +308,8 @@ describe('an SSH rename', () => {
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
|
||||
expect(renameNoClobber).toHaveBeenCalledWith(note, join(base, 'note.md'))
|
||||
expect(await readdir(docFolder)).toEqual(['note.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rm, stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getRuntimePathBasename } from '../../shared/cross-platform-path'
|
||||
import { sanitizeLocalDownloadFilename } from '../local-download-filename'
|
||||
import { promoteLocalDownloadedFolder } from '../local-downloaded-folder-promotion'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { isENOENT } from './filesystem-path-containment'
|
||||
|
||||
type DownloadFolderResult = { canceled: true } | { canceled: false; destinationPath: string }
|
||||
|
||||
function validateRequiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${label} is required`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function createSiblingTransferPath(destinationPath: string, suffix: string): string {
|
||||
// Why: promotion uses rename/no-clobber operations that must stay on the
|
||||
// destination volume, so transfer paths intentionally remain siblings.
|
||||
return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`)
|
||||
}
|
||||
|
||||
async function assertDownloadFolderDestinationAvailable(destinationPath: string): Promise<void> {
|
||||
try {
|
||||
await stat(destinationPath)
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('Destination folder already exists')
|
||||
}
|
||||
|
||||
async function cleanupLocalTransferDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await rm(dirPath, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
// Why: cleanup must not mask the transfer error, but a leaked recursive
|
||||
// download tree needs enough visibility to diagnose and remove it.
|
||||
console.warn(`[filesystem] Failed to remove temporary folder download '${dirPath}'`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: keep folder-download IPC out of filesystem.ts — that module is already large.
|
||||
export function registerFilesystemDownloadFolderHandlers(): void {
|
||||
ipcMain.handle(
|
||||
'fs:downloadFolder',
|
||||
async (
|
||||
event,
|
||||
args: { dirPath?: string; connectionId?: string }
|
||||
): Promise<DownloadFolderResult> => {
|
||||
const dirPath = validateRequiredString(args?.dirPath, 'dirPath')
|
||||
const connectionId = validateRequiredString(args?.connectionId, 'connectionId')
|
||||
const provider = requireSshFilesystemProvider(connectionId)
|
||||
if (!provider.downloadFolder) {
|
||||
throw new Error(
|
||||
'Remote folder download is unavailable. Reconnect the SSH target and retry.'
|
||||
)
|
||||
}
|
||||
const abortController = new AbortController()
|
||||
const abortOnSenderDestroyed = (): void => {
|
||||
abortController.abort(new Error('Folder download canceled because the window closed'))
|
||||
}
|
||||
event.sender.once('destroyed', abortOnSenderDestroyed)
|
||||
if (event.sender.isDestroyed()) {
|
||||
abortOnSenderDestroyed()
|
||||
}
|
||||
try {
|
||||
abortController.signal.throwIfAborted()
|
||||
const remoteBasename = getRuntimePathBasename(dirPath)
|
||||
const destinationBasename = sanitizeLocalDownloadFilename(remoteBasename)
|
||||
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
// Why: after the local capability/abort checks, open the picker before
|
||||
// remote tree validation so SSH latency does not delay click feedback.
|
||||
const dialogOptions: Electron.OpenDialogOptions = {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
}
|
||||
const dialogResult = parentWindow
|
||||
? await dialog.showOpenDialog(parentWindow, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions)
|
||||
const destinationParent = dialogResult.filePaths?.[0]
|
||||
if (dialogResult.canceled || !destinationParent) {
|
||||
return { canceled: true }
|
||||
}
|
||||
abortController.signal.throwIfAborted()
|
||||
|
||||
const destinationPath = join(destinationParent, destinationBasename)
|
||||
await assertDownloadFolderDestinationAvailable(destinationPath)
|
||||
|
||||
const tempPath = createSiblingTransferPath(destinationPath, 'download')
|
||||
try {
|
||||
await provider.downloadFolder(dirPath, tempPath, { signal: abortController.signal })
|
||||
abortController.signal.throwIfAborted()
|
||||
await promoteLocalDownloadedFolder(tempPath, destinationPath, abortController.signal)
|
||||
return { canceled: false, destinationPath }
|
||||
} finally {
|
||||
await cleanupLocalTransferDirectory(tempPath)
|
||||
}
|
||||
} finally {
|
||||
event.sender.removeListener('destroyed', abortOnSenderDestroyed)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -20,10 +20,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => ({ ...mocks, writeFile: vi.fn() }))
|
||||
vi.mock('./filesystem-download-folder', () => ({
|
||||
registerFilesystemDownloadFolderHandlers: vi.fn()
|
||||
}))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({ requireSshFilesystemProvider: vi.fn() }))
|
||||
|
||||
import { createFilesystemHandlerContext } from './filesystem/filesystem-handler-context'
|
||||
import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers'
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
renameMock,
|
||||
rmMock,
|
||||
writeFileMock,
|
||||
getSshFilesystemProviderMock,
|
||||
promoteLocalDownloadedFolderMock,
|
||||
resetFilesystemIpcMocks
|
||||
} from './filesystem-test-harness'
|
||||
|
||||
@@ -27,10 +25,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -44,10 +38,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
@@ -285,14 +275,7 @@ describe('registerFilesystemHandlers', () => {
|
||||
expect(rmMock).toHaveBeenCalledWith(backupPath, { force: true })
|
||||
})
|
||||
|
||||
it('downloads remote folders into a temporary sibling before promotion', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
|
||||
downloadFolder: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] })
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
it('refuses remote folder downloads before opening a picker', async () => {
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
@@ -300,208 +283,10 @@ describe('registerFilesystemHandlers', () => {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({ canceled: false, destinationPath: path.join('/downloads', 'src') })
|
||||
|
||||
const tempPath = provider.downloadFolder.mock.calls[0][1]
|
||||
expect(path.dirname(tempPath)).toBe(path.normalize('/downloads'))
|
||||
expect(showOpenDialogMock).toHaveBeenCalledWith({
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
expect(provider.downloadFolder).toHaveBeenCalledWith(
|
||||
'/remote/src',
|
||||
tempPath,
|
||||
expect.objectContaining({ signal: expect.anything() })
|
||||
).rejects.toThrow(
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
)
|
||||
expect(folderDownloadSender.listenerCount('destroyed')).toBe(0)
|
||||
expect(promoteLocalDownloadedFolderMock).toHaveBeenCalledWith(
|
||||
tempPath,
|
||||
path.join('/downloads', 'src'),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('returns canceled remote folder downloads without transferring', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
|
||||
downloadFolder: vi.fn()
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] })
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({ canceled: true })
|
||||
|
||||
expect(provider.downloadFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts before opening the folder picker when the renderer is already destroyed', async () => {
|
||||
const provider = {
|
||||
downloadFolder: vi.fn()
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
folderDownloadSender.isDestroyed.mockReturnValue(true)
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('window closed')
|
||||
|
||||
expect(showOpenDialogMock).not.toHaveBeenCalled()
|
||||
expect(provider.downloadFolder).not.toHaveBeenCalled()
|
||||
expect(folderDownloadSender.listenerCount('destroyed')).toBe(0)
|
||||
})
|
||||
|
||||
it('opens the folder picker before SSH folder validation', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
|
||||
downloadFolder: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockImplementation(async () => {
|
||||
expect(provider.downloadFolder).not.toHaveBeenCalled()
|
||||
return { canceled: false, filePaths: ['/downloads'] }
|
||||
})
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({ canceled: false, destinationPath: path.join('/downloads', 'src') })
|
||||
|
||||
expect(provider.downloadFolder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects remote folder downloads when the destination folder already exists', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
|
||||
downloadFolder: vi.fn()
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] })
|
||||
statMock.mockResolvedValue({ isDirectory: () => true })
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('Destination folder already exists')
|
||||
|
||||
expect(provider.downloadFolder).not.toHaveBeenCalled()
|
||||
expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects remote folder downloads when the SSH provider cannot transfer folders', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 })
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] })
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('Remote folder download is unavailable')
|
||||
|
||||
expect(showOpenDialogMock).not.toHaveBeenCalled()
|
||||
expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up a temporary remote folder download when transfer fails', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
|
||||
downloadFolder: vi.fn().mockRejectedValue(new Error('transfer failed'))
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] })
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('transfer failed')
|
||||
|
||||
const tempPath = provider.downloadFolder.mock.calls[0][1]
|
||||
expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled()
|
||||
expect(rmMock).toHaveBeenCalledWith(tempPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('logs a recursive temporary-folder cleanup failure without masking the transfer error', async () => {
|
||||
const provider = {
|
||||
downloadFolder: vi.fn().mockRejectedValue(new Error('transfer failed'))
|
||||
}
|
||||
const cleanupError = new Error('cleanup denied')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] })
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
rmMock.mockRejectedValueOnce(cleanupError)
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('transfer failed')
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to remove temporary folder download'),
|
||||
cleanupError
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts and cleans up a remote folder download when its renderer closes', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
|
||||
downloadFolder: vi.fn(
|
||||
(_source: string, _destination: string, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] })
|
||||
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
const result = handlers.get('fs:downloadFolder')!(folderDownloadEvent, {
|
||||
dirPath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
await vi.waitFor(() => expect(provider.downloadFolder).toHaveBeenCalledTimes(1))
|
||||
folderDownloadSender.emit('destroyed')
|
||||
|
||||
await expect(result).rejects.toThrow('window closed')
|
||||
const tempPath = provider.downloadFolder.mock.calls[0][1]
|
||||
expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled()
|
||||
expect(rmMock).toHaveBeenCalledWith(tempPath, { recursive: true, force: true })
|
||||
expect(folderDownloadSender.listenerCount('destroyed')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
store,
|
||||
WORKTREE_FEATURE_PATH,
|
||||
commitChangesMock,
|
||||
getSshGitProviderMock,
|
||||
resolveCommitMessageSettingsMock,
|
||||
generatePullRequestFieldsFromContextMock,
|
||||
getPullRequestDraftContextMock,
|
||||
@@ -25,10 +24,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -42,10 +37,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
@@ -94,7 +85,6 @@ describe('registerFilesystemHandlers', () => {
|
||||
|
||||
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 })
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
@@ -114,7 +104,6 @@ describe('registerFilesystemHandlers', () => {
|
||||
|
||||
it('rejects git:remoteCommitUrl with a short hash before SSH dispatch', async () => {
|
||||
const sshRemoteCommitUrlMock = vi.fn()
|
||||
getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock })
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
@@ -144,7 +133,6 @@ describe('registerFilesystemHandlers', () => {
|
||||
|
||||
it('rejects git:commit with whitespace-only message before SSH dispatch', async () => {
|
||||
const sshCommitMock = vi.fn().mockResolvedValue({ success: true })
|
||||
getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock })
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
discardChangesMock,
|
||||
checkIgnoredPathsMock,
|
||||
listWorktreesMock,
|
||||
getSshGitProviderMock,
|
||||
resetFilesystemIpcMocks
|
||||
} from './filesystem-test-harness'
|
||||
|
||||
@@ -32,10 +31,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -49,10 +44,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
@@ -338,7 +329,6 @@ describe('registerFilesystemHandlers', () => {
|
||||
})
|
||||
|
||||
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']) {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { lstat, readdir, realpath } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, sep } from 'node:path'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
|
||||
|
||||
export async function captureLocalUploadRoot(
|
||||
sourcePath: string,
|
||||
sourceStat: Awaited<ReturnType<typeof lstat>>
|
||||
): Promise<string> {
|
||||
const rootRealPath = await realpath(sourcePath)
|
||||
const rootRealStat = await lstat(rootRealPath)
|
||||
if (
|
||||
statIdentityPartChanged(sourceStat.ino, rootRealStat.ino) ||
|
||||
statIdentityPartChanged(sourceStat.dev, rootRealStat.dev) ||
|
||||
!rootRealStat.isDirectory()
|
||||
) {
|
||||
throw new Error(`Upload source changed while being inspected: ${sourcePath}`)
|
||||
}
|
||||
return rootRealPath
|
||||
}
|
||||
|
||||
export async function preScanSshImportDirectory(
|
||||
dirPath: string,
|
||||
remotePathFlavor: RemotePathFlavor
|
||||
): Promise<boolean> {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
assertSafeRemotePathSegment(entry.name, remotePathFlavor)
|
||||
if (entry.isSymbolicLink()) {
|
||||
return true
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
const childPath = join(dirPath, entry.name)
|
||||
if (await preScanSshImportDirectory(childPath, remotePathFlavor)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function uploadSshImportDirectory(
|
||||
provider: IFilesystemProvider,
|
||||
uploadSession: FileUploadSession,
|
||||
localDir: string,
|
||||
remoteDir: string,
|
||||
rootRealPath: string,
|
||||
remotePathFlavor: RemotePathFlavor,
|
||||
assertCurrent?: () => void
|
||||
): Promise<void> {
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
|
||||
const entries = await readdir(localDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
assertSafeRemotePathSegment(entry.name, remotePathFlavor)
|
||||
const localPath = join(localDir, entry.name)
|
||||
const remotePath = `${remoteDir}/${entry.name}`
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
|
||||
const statResult = await lstat(localPath)
|
||||
|
||||
// Why: skip symlinks and special files even after the up-front pre-scan;
|
||||
// this closes the TOCTOU gap if one is created during upload.
|
||||
if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (statResult.isDirectory()) {
|
||||
assertCurrent?.()
|
||||
await provider.createDirNoClobber(remotePath)
|
||||
await uploadSshImportDirectory(
|
||||
provider,
|
||||
uploadSession,
|
||||
localPath,
|
||||
remotePath,
|
||||
rootRealPath,
|
||||
remotePathFlavor,
|
||||
assertCurrent
|
||||
)
|
||||
continue
|
||||
}
|
||||
assertCurrent?.()
|
||||
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function statIdentityPartChanged(
|
||||
left: number | bigint | undefined,
|
||||
right: number | bigint | undefined
|
||||
): boolean {
|
||||
const leftKnown = left !== undefined && left !== 0 && left !== 0n
|
||||
const rightKnown = right !== undefined && right !== 0 && right !== 0n
|
||||
return leftKnown && rightKnown && left !== right
|
||||
}
|
||||
|
||||
async function assertLocalUploadPathInsideRoot(
|
||||
rootRealPath: string,
|
||||
candidatePath: string
|
||||
): Promise<void> {
|
||||
const candidateRealPath = await realpath(candidatePath)
|
||||
const relativeToRoot = relative(rootRealPath, candidateRealPath)
|
||||
if (
|
||||
relativeToRoot !== '' &&
|
||||
(relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot))
|
||||
) {
|
||||
throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import type * as SshTargetRegistry from '../ssh/ssh-target-registry'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
const {
|
||||
handleMock,
|
||||
lstatMock,
|
||||
mkdirMock,
|
||||
realpathMock,
|
||||
copyFileMock,
|
||||
readdirMock,
|
||||
getConnMgrMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
mkdirMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
copyFileMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
getConnMgrMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
|
||||
vi.mock('fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
mkdir: mkdirMock,
|
||||
rename: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
realpath: realpathMock,
|
||||
copyFile: copyFileMock,
|
||||
readdir: readdirMock
|
||||
}))
|
||||
vi.mock('../ssh/ssh-target-registry', async () => {
|
||||
const actual = await vi.importActual<typeof SshTargetRegistry>('../ssh/ssh-target-registry')
|
||||
return { ...actual, getSshConnectionManager: getConnMgrMock }
|
||||
})
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
advanceSshConnectionGeneration,
|
||||
resetSshConnectionGenerations
|
||||
} from '../ssh/ssh-connection-generation'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
{
|
||||
id: 'r1',
|
||||
path: path.resolve('/workspace/repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
|
||||
}
|
||||
const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
|
||||
function createProvider(uploadSession: FileUploadSession): IFilesystemProvider {
|
||||
return {
|
||||
readDir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
openFileUploadSession: vi.fn().mockResolvedValue(uploadSession),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
writeFileBase64: vi.fn(),
|
||||
writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockRejectedValue(enoent()),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined),
|
||||
createFile: vi.fn(),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
|
||||
rename: vi.fn(),
|
||||
renameNoClobber: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
search: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
watch: vi.fn()
|
||||
} as unknown as IFilesystemProvider
|
||||
}
|
||||
|
||||
describe('fs:importExternalPaths — SSH operations', () => {
|
||||
const destDir = '/home/user/project/src'
|
||||
const connId = 'ssh-conn-1'
|
||||
let provider: IFilesystemProvider
|
||||
let uploadSession: FileUploadSession
|
||||
const makeConn = () => ({
|
||||
getState: () => ({ status: 'connected' }),
|
||||
sftp: vi.fn()
|
||||
})
|
||||
const mockDir = (p: string): void => {
|
||||
const rp = path.resolve(p)
|
||||
lstatMock.mockImplementation(async (x: string) => {
|
||||
if (x === rp) {
|
||||
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
}
|
||||
const mockFile = (p: string): void => {
|
||||
const rp = path.resolve(p)
|
||||
lstatMock.mockImplementation(async (x: string) => {
|
||||
if (x === rp) {
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
}
|
||||
const invoke = (args: Record<string, unknown>) =>
|
||||
handlers.get('fs:importExternalPaths')!(null, {
|
||||
...args,
|
||||
...(typeof args.connectionId === 'string'
|
||||
? {
|
||||
expectedSshTargetId: args.expectedSshTargetId ?? args.connectionId,
|
||||
expectedSshConnectionGeneration: args.expectedSshConnectionGeneration ?? 0
|
||||
}
|
||||
: {})
|
||||
}) as Promise<{
|
||||
results: Record<string, unknown>[]
|
||||
}>
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
;[
|
||||
handleMock,
|
||||
lstatMock,
|
||||
mkdirMock,
|
||||
realpathMock,
|
||||
copyFileMock,
|
||||
readdirMock,
|
||||
getConnMgrMock
|
||||
].forEach((m) => m.mockReset())
|
||||
handleMock.mockImplementation((ch: string, h: never) => {
|
||||
handlers.set(ch, h)
|
||||
})
|
||||
realpathMock.mockImplementation(async (p: string) => p)
|
||||
lstatMock.mockRejectedValue(enoent())
|
||||
readdirMock.mockResolvedValue([])
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
|
||||
uploadSession = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn()
|
||||
}
|
||||
provider = createProvider(uploadSession)
|
||||
registerSshFilesystemProvider(connId, provider)
|
||||
registerFilesystemMutationHandlers(store as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshFilesystemProvider(connId)
|
||||
resetSshConnectionGenerations()
|
||||
})
|
||||
|
||||
it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => {
|
||||
resetSshConnectionGenerations(71)
|
||||
const stagedGeneration = advanceSshConnectionGeneration(connId)
|
||||
const sourcePath = path.resolve('/tmp/dropped/restart.txt')
|
||||
lstatMock.mockImplementation(async (candidate: string) => {
|
||||
if (candidate !== sourcePath) {
|
||||
throw enoent()
|
||||
}
|
||||
resetSshConnectionGenerations(72)
|
||||
advanceSshConnectionGeneration(connId)
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
})
|
||||
|
||||
const { results } = await invoke({
|
||||
sourcePaths: [sourcePath],
|
||||
destDir,
|
||||
connectionId: connId,
|
||||
expectedSshTargetId: connId,
|
||||
expectedSshConnectionGeneration: stagedGeneration
|
||||
})
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: 'SSH connection changed; refresh and try again'
|
||||
})
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deconflicts file names via provider stat', async () => {
|
||||
mockFile('/tmp/dropped/logo.png')
|
||||
vi.mocked(provider.stat).mockImplementation(async (p: string) => {
|
||||
if (p === `${destDir}/logo.png`) {
|
||||
return { type: 'file', size: 1, mtime: 1 }
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/logo.png'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
destPath: `${destDir}/logo copy.png`,
|
||||
renamed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects symlink sources', async () => {
|
||||
const rp = path.resolve('/tmp/dropped/link.txt')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === rp) {
|
||||
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/link.txt'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
|
||||
})
|
||||
|
||||
it('handles partial failure with correct per-item results', async () => {
|
||||
const sources = ['/tmp/dropped/good.txt', '/tmp/dropped/bad.txt', '/tmp/dropped/ok.txt']
|
||||
const resolved = new Set(sources.map((s) => path.resolve(s)))
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (resolved.has(p)) {
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
vi.mocked(uploadSession.uploadFile).mockImplementation(async (_localPath, remotePath) => {
|
||||
if (remotePath.endsWith('/bad.txt')) {
|
||||
throw new Error('permission denied')
|
||||
}
|
||||
})
|
||||
|
||||
const { results } = await invoke({ sourcePaths: sources, destDir, connectionId: connId })
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results[0]).toMatchObject({ status: 'imported' })
|
||||
expect(results[1]).toMatchObject({ status: 'failed', reason: 'permission denied' })
|
||||
expect(results[2]).toMatchObject({ status: 'imported' })
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not delete a file another client created during an exclusive-upload race', async () => {
|
||||
mockFile('/tmp/dropped/report.txt')
|
||||
vi.mocked(uploadSession.uploadFile).mockRejectedValue(
|
||||
Object.assign(new Error('EEXIST: destination already exists'), { code: 'EEXIST' })
|
||||
)
|
||||
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/report.txt'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: 'EEXIST: destination already exists'
|
||||
})
|
||||
// Why: a failed exclusive create does not prove Orca owns the destination.
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads directories via provider createDirNoClobber and binary writes', async () => {
|
||||
const root = path.resolve('/tmp/dropped/assets')
|
||||
const child = path.join(root, 'logo.png')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === root) {
|
||||
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
}
|
||||
if (p === child) {
|
||||
return {
|
||||
size: 3,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
readdirMock.mockImplementation(async (p: string) =>
|
||||
p === root
|
||||
? [
|
||||
{
|
||||
name: 'logo.png',
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/assets'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
|
||||
expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`)
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(child, `${destDir}/assets/logo.png`, {
|
||||
exclusive: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports per-item failure when deconfliction throws', async () => {
|
||||
mockFile('/tmp/dropped/file.txt')
|
||||
vi.mocked(provider.stat).mockRejectedValue(new Error('Remote connection not found'))
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/file.txt'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'failed', reason: 'Remote connection not found' })
|
||||
})
|
||||
|
||||
it('reports failure when creating a directory rejects', async () => {
|
||||
mockDir('/tmp/dropped/mydir')
|
||||
vi.mocked(provider.createDirNoClobber).mockRejectedValue(new Error('permission denied'))
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/mydir'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' })
|
||||
})
|
||||
|
||||
it('removes a created SSH directory import root when nested upload fails', async () => {
|
||||
const root = path.resolve('/tmp/dropped/assets')
|
||||
const child = path.join(root, 'logo.png')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === root) {
|
||||
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
}
|
||||
if (p === child) {
|
||||
return {
|
||||
size: 3,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
readdirMock.mockImplementation(async (p: string) =>
|
||||
p === root
|
||||
? [
|
||||
{
|
||||
name: 'logo.png',
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
vi.mocked(uploadSession.uploadFile).mockRejectedValue(new Error('disk full'))
|
||||
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/assets'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' })
|
||||
expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`)
|
||||
expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true)
|
||||
})
|
||||
|
||||
it('deconflicts directory names via provider stat', async () => {
|
||||
mockDir('/tmp/dropped/assets')
|
||||
vi.mocked(provider.stat).mockImplementation(async (p: string) => {
|
||||
if (p === `${destDir}/assets`) {
|
||||
return { type: 'directory', size: 1, mtime: 1 }
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/assets'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
destPath: `${destDir}/assets copy`,
|
||||
renamed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects directory containing nested symlinks', async () => {
|
||||
mockDir('/tmp/dropped/project')
|
||||
const rd = path.resolve('/tmp/dropped/project')
|
||||
readdirMock.mockImplementation(async (p: string) => {
|
||||
if (p === rd) {
|
||||
return [
|
||||
{
|
||||
name: 'l.txt',
|
||||
isFile: () => false,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => true
|
||||
}
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/project'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports skipped when source lstat returns EACCES', async () => {
|
||||
const rp = path.resolve('/tmp/dropped/secret.txt')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === rp) {
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/secret.txt'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'skipped', reason: 'permission-denied' })
|
||||
})
|
||||
})
|
||||
@@ -1,277 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import type * as SshTargetRegistry from '../ssh/ssh-target-registry'
|
||||
|
||||
const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({
|
||||
getConnMgrMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
providerMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
realpathMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
readdir: readdirMock,
|
||||
realpath: realpathMock
|
||||
}))
|
||||
vi.mock('./filesystem-path-containment', () => ({
|
||||
isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT'
|
||||
}))
|
||||
vi.mock('../ssh/ssh-target-registry', async () => {
|
||||
const actual = await vi.importActual<typeof SshTargetRegistry>('../ssh/ssh-target-registry')
|
||||
return { ...actual, getSshConnectionManager: getConnMgrMock }
|
||||
})
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: providerMock
|
||||
}))
|
||||
|
||||
import { importExternalPathsSsh } from './filesystem-import-ssh'
|
||||
|
||||
function createProvider(uploadSession: FileUploadSession): IFilesystemProvider {
|
||||
const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
return {
|
||||
stat: vi.fn().mockRejectedValue(missing),
|
||||
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined),
|
||||
openFileUploadSession: vi.fn().mockResolvedValue(uploadSession)
|
||||
} as unknown as IFilesystemProvider
|
||||
}
|
||||
|
||||
describe('SSH import remote path safety', () => {
|
||||
const connectionId = 'ssh-windows'
|
||||
const destDir = 'C:/Users/me/project/.orca/drops'
|
||||
let provider: IFilesystemProvider
|
||||
let uploadSession: FileUploadSession
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getConnMgrMock.mockReturnValue({
|
||||
getConnection: () => ({ getState: () => ({ status: 'connected' }) })
|
||||
})
|
||||
uploadSession = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn()
|
||||
}
|
||||
provider = createProvider(uploadSession)
|
||||
providerMock.mockReturnValue(provider)
|
||||
readdirMock.mockResolvedValue([])
|
||||
realpathMock.mockImplementation(async (value: string) => value)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects a POSIX filename containing Windows traversal before remote stat',
|
||||
async () => {
|
||||
const sourcePath = '/tmp/..\\..\\.ssh\\orca_drop'
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('Unsafe')
|
||||
})
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
expect(provider.stat).not.toHaveBeenCalled()
|
||||
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['report.txt:orca', 'NUL', 'trailing.', 'question?.txt'])(
|
||||
'rejects Windows-special top-level name %j before remote stat',
|
||||
async (name) => {
|
||||
const { results } = await importExternalPathsSsh([`/tmp/${name}`], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('Unsafe')
|
||||
})
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
expect(provider.stat).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a nested Windows traversal name before creating the import root', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
readdirMock.mockResolvedValue([
|
||||
{
|
||||
name: '..\\..\\.ssh\\orca_drop',
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
])
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('Unsafe')
|
||||
})
|
||||
expect(provider.stat).not.toHaveBeenCalled()
|
||||
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps exclusive creation for safe file names', async () => {
|
||||
const sourcePath = path.resolve('/tmp/report.txt')
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', destPath: `${destDir}/report.txt` })
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(sourcePath, `${destDir}/report.txt`, {
|
||||
exclusive: true
|
||||
})
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'preserves backslashes in valid POSIX names for POSIX remotes',
|
||||
async () => {
|
||||
const sourcePath = path.resolve('/tmp/notes\\2026.txt')
|
||||
const posixDestDir = '/home/me/project'
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], posixDestDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
destPath: `${posixDestDir}/notes\\2026.txt`
|
||||
})
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
sourcePath,
|
||||
`${posixDestDir}/notes\\2026.txt`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('streams an N-file directory through guarded per-file uploads', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
const names = ['one.txt', 'two.txt', 'three.txt']
|
||||
const entries = names.map((name) => ({
|
||||
name,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}))
|
||||
lstatMock.mockImplementation(async (value: string) =>
|
||||
value === sourcePath
|
||||
? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
: { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
)
|
||||
readdirMock.mockResolvedValue(entries)
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledTimes(3)
|
||||
for (const name of names) {
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
path.join(sourcePath, name),
|
||||
`${destDir}/assets/${name}`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips special entries without failing the directory import', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
const regularPath = path.join(sourcePath, 'report.txt')
|
||||
const socketPath = path.join(sourcePath, 'server.sock')
|
||||
const entries = [
|
||||
{
|
||||
name: 'report.txt',
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
},
|
||||
{
|
||||
name: 'server.sock',
|
||||
isFile: () => false,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
]
|
||||
lstatMock.mockImplementation(async (value: string) => {
|
||||
if (value === sourcePath) {
|
||||
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
}
|
||||
if (value === regularPath) {
|
||||
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
}
|
||||
expect(value).toBe(socketPath)
|
||||
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
})
|
||||
readdirMock.mockResolvedValue(entries)
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledOnce()
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
regularPath,
|
||||
`${destDir}/assets/report.txt`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a selected root replaced after its realpath is captured', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
realpathMock.mockResolvedValueOnce(sourcePath).mockResolvedValueOnce('/private/replacement')
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('escapes selected directory')
|
||||
})
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true)
|
||||
})
|
||||
|
||||
it('rejects a selected root replaced while its realpath is captured', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
const directoryStat = (ino: number) => ({
|
||||
dev: 1,
|
||||
ino,
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
lstatMock.mockResolvedValueOnce(directoryStat(1)).mockResolvedValueOnce(directoryStat(2))
|
||||
realpathMock.mockResolvedValue('/private/replacement')
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('changed while being inspected')
|
||||
})
|
||||
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
|
||||
/** Whether a remote path exists; only a definite "missing" answer reads as false. */
|
||||
export async function remotePathExists(
|
||||
provider: IFilesystemProvider,
|
||||
remotePath: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await provider.stat(remotePath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isRemoteMissingError(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isRemoteMissingError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
('code' in error && error.code === 'ENOENT') ||
|
||||
/\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test(
|
||||
error.message
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { constants } from 'node:fs'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import type * as SshTargetRegistry from '../ssh/ssh-target-registry'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
const {
|
||||
handleMock,
|
||||
lstatMock,
|
||||
mkdirMock,
|
||||
realpathMock,
|
||||
copyFileMock,
|
||||
openMock,
|
||||
readdirMock,
|
||||
unlinkMock,
|
||||
getConnMgrMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
mkdirMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
copyFileMock: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
unlinkMock: vi.fn(),
|
||||
getConnMgrMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/orca-test-user-data' },
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
vi.mock('fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
mkdir: mkdirMock,
|
||||
rename: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
realpath: realpathMock,
|
||||
copyFile: copyFileMock,
|
||||
open: openMock,
|
||||
readdir: readdirMock,
|
||||
unlink: unlinkMock,
|
||||
rm: vi.fn()
|
||||
}))
|
||||
vi.mock('../ssh/ssh-target-registry', async () => {
|
||||
const actual = await vi.importActual<typeof SshTargetRegistry>('../ssh/ssh-target-registry')
|
||||
return { ...actual, getSshConnectionManager: getConnMgrMock }
|
||||
})
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation'
|
||||
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
{
|
||||
id: 'r1',
|
||||
path: path.resolve('/workspace/repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
|
||||
}
|
||||
const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
|
||||
function createProvider(uploadSession: FileUploadSession): IFilesystemProvider {
|
||||
return {
|
||||
readDir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
openFileUploadSession: vi.fn().mockResolvedValue(uploadSession),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
writeFileBase64: vi.fn(),
|
||||
writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockRejectedValue(enoent()),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined),
|
||||
createFile: vi.fn(),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
|
||||
rename: vi.fn(),
|
||||
renameNoClobber: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
search: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
watch: vi.fn()
|
||||
} as unknown as IFilesystemProvider
|
||||
}
|
||||
|
||||
describe('fs:importExternalPaths — SSH routing & connection', () => {
|
||||
const destDir = '/home/user/project/src'
|
||||
const connId = 'ssh-conn-1'
|
||||
let provider: IFilesystemProvider
|
||||
let uploadSession: FileUploadSession
|
||||
|
||||
const makeConn = (status = 'connected') => ({
|
||||
getState: () => ({ status }),
|
||||
sftp: vi.fn()
|
||||
})
|
||||
const mockFile = (p: string): void => {
|
||||
const rp = path.resolve(p)
|
||||
lstatMock.mockImplementation(async (x: string) => {
|
||||
if (x === rp) {
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
}
|
||||
const invoke = (args: Record<string, unknown>) =>
|
||||
handlers.get('fs:importExternalPaths')!(
|
||||
null,
|
||||
typeof args.connectionId === 'string'
|
||||
? {
|
||||
...args,
|
||||
expectedSshTargetId: args.connectionId,
|
||||
expectedSshConnectionGeneration: 0
|
||||
}
|
||||
: args
|
||||
) as Promise<{
|
||||
results: Record<string, unknown>[]
|
||||
}>
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
resetSshConnectionGenerations()
|
||||
;[
|
||||
handleMock,
|
||||
lstatMock,
|
||||
mkdirMock,
|
||||
realpathMock,
|
||||
copyFileMock,
|
||||
openMock,
|
||||
readdirMock,
|
||||
unlinkMock,
|
||||
getConnMgrMock
|
||||
].forEach((m) => m.mockReset())
|
||||
handleMock.mockImplementation((ch: string, h: never) => {
|
||||
handlers.set(ch, h)
|
||||
})
|
||||
realpathMock.mockImplementation(async (p: string) => p)
|
||||
lstatMock.mockRejectedValue(enoent())
|
||||
openMock.mockImplementation(async (_p: string, flags: unknown) => {
|
||||
if (flags === 'wx') {
|
||||
return {
|
||||
createWriteStream: () =>
|
||||
new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback()
|
||||
}
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
return {
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true
|
||||
}),
|
||||
createReadStream: () => Readable.from([Buffer.from('file-content')]),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
unlinkMock.mockResolvedValue(undefined)
|
||||
uploadSession = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn()
|
||||
}
|
||||
provider = createProvider(uploadSession)
|
||||
registerSshFilesystemProvider(connId, provider)
|
||||
registerFilesystemMutationHandlers(store as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshFilesystemProvider(connId)
|
||||
})
|
||||
|
||||
it('routes SSH imports through the filesystem provider when connectionId is present', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
|
||||
mockFile('/tmp/dropped/file.txt')
|
||||
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/file.txt'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' })
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
path.resolve('/tmp/dropped/file.txt'),
|
||||
`${destDir}/file.txt`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
expect(uploadSession.close).toHaveBeenCalledOnce()
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to local import when connectionId is absent', async () => {
|
||||
mockFile('/tmp/dropped/file.txt')
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/file.txt'],
|
||||
destDir: path.resolve('/workspace/repo/src')
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'imported' })
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
path.resolve('/tmp/dropped/file.txt'),
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty results without opening SFTP or requiring a provider', async () => {
|
||||
unregisterSshFilesystemProvider(connId)
|
||||
const conn = makeConn()
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => conn })
|
||||
const { results } = await invoke({ sourcePaths: [], destDir, connectionId: connId })
|
||||
expect(results).toHaveLength(0)
|
||||
expect(conn.sftp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when connectionId has no matching connection', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => null })
|
||||
await expect(
|
||||
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
|
||||
).rejects.toThrow('No SSH connection')
|
||||
})
|
||||
|
||||
it('throws when connection is reconnecting', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('reconnecting') })
|
||||
await expect(
|
||||
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
|
||||
).rejects.toThrow('reconnecting')
|
||||
})
|
||||
|
||||
it('throws when connection is not active', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('disconnected') })
|
||||
await expect(
|
||||
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
|
||||
).rejects.toThrow('not active')
|
||||
})
|
||||
|
||||
it('throws when the SSH filesystem provider is unavailable', async () => {
|
||||
unregisterSshFilesystemProvider(connId)
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
|
||||
await expect(
|
||||
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
})
|
||||
|
||||
it('uploads terminal-drop staging marker through provider writes', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
|
||||
mockFile('/tmp/dropped/file.txt')
|
||||
|
||||
await invoke({
|
||||
sourcePaths: ['/tmp/dropped/file.txt'],
|
||||
destDir: '/home/user/project/.orca/drops',
|
||||
connectionId: connId,
|
||||
ensureDir: true
|
||||
})
|
||||
|
||||
expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca')
|
||||
expect(provider.writeFile).toHaveBeenCalledWith(
|
||||
'/home/user/project/.orca/.gitignore',
|
||||
'*\n!.gitignore\n'
|
||||
)
|
||||
expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca/drops')
|
||||
})
|
||||
})
|
||||
@@ -1,195 +0,0 @@
|
||||
import { basename, posix, resolve } from 'node:path'
|
||||
import { classifyImportSource } from './filesystem-import-source-stat'
|
||||
import { deconflictCopyName } from '../../shared/import-copy-name'
|
||||
import { getSshConnectionManager } from '../ssh/ssh-target-registry'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import type { ImportItemResult } from '../../shared/filesystem-import-result-types'
|
||||
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { remotePathExists } from './filesystem-import-ssh-remote-existence'
|
||||
import {
|
||||
captureLocalUploadRoot,
|
||||
preScanSshImportDirectory,
|
||||
uploadSshImportDirectory
|
||||
} from './filesystem-import-ssh-directory'
|
||||
import { errorMessage } from '../../shared/error-message'
|
||||
|
||||
// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so
|
||||
// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows.
|
||||
export async function importExternalPathsSsh(
|
||||
sourcePaths: string[],
|
||||
destDir: string,
|
||||
connectionId: string,
|
||||
options?: { ensureDir?: boolean; assertCurrent?: () => void }
|
||||
): Promise<{ results: ImportItemResult[] }> {
|
||||
if (sourcePaths.length === 0) {
|
||||
return { results: [] }
|
||||
}
|
||||
|
||||
const connManager = getSshConnectionManager()
|
||||
const conn = connManager?.getConnection(connectionId)
|
||||
if (!conn) {
|
||||
throw new Error(`No SSH connection for "${connectionId}"`)
|
||||
}
|
||||
|
||||
const state = conn.getState()
|
||||
if (state.status !== 'connected') {
|
||||
if (state.status === 'reconnecting') {
|
||||
throw new Error('SSH connection is reconnecting — please try again in a moment')
|
||||
}
|
||||
throw new Error('SSH connection is not active — please reconnect and try again')
|
||||
}
|
||||
|
||||
const provider = requireSshFilesystemProvider(connectionId)
|
||||
|
||||
if (options?.ensureDir) {
|
||||
// Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist
|
||||
// before the first upload. .orca/ is reserved as Orca-owned remote state;
|
||||
// see docs/terminal-drop-ssh.md.
|
||||
await ensureDropStagingDir(provider, destDir, options.assertCurrent)
|
||||
}
|
||||
|
||||
const results: ImportItemResult[] = []
|
||||
const reservedNames = new Set<string>()
|
||||
if (!provider.openFileUploadSession) {
|
||||
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
|
||||
}
|
||||
options?.assertCurrent?.()
|
||||
const uploadSession = await provider.openFileUploadSession()
|
||||
// Why: filename legality follows the remote filesystem, not the client's OS.
|
||||
const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
try {
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const result = await importOneSourceSsh(
|
||||
provider,
|
||||
uploadSession,
|
||||
sourcePath,
|
||||
destDir,
|
||||
reservedNames,
|
||||
remotePathFlavor,
|
||||
options?.assertCurrent
|
||||
)
|
||||
results.push(result)
|
||||
if (result.status === 'imported') {
|
||||
// Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt).
|
||||
// Node's basename() uses the OS separator, which on Windows would
|
||||
// return the entire string instead of just the filename.
|
||||
reservedNames.add(posix.basename(result.destPath))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
uploadSession.close()
|
||||
}
|
||||
|
||||
return { results }
|
||||
}
|
||||
|
||||
async function importOneSourceSsh(
|
||||
provider: IFilesystemProvider,
|
||||
uploadSession: FileUploadSession,
|
||||
sourcePath: string,
|
||||
destDir: string,
|
||||
reservedNames: Set<string>,
|
||||
remotePathFlavor: RemotePathFlavor,
|
||||
assertCurrent?: () => void
|
||||
): Promise<ImportItemResult> {
|
||||
const resolvedSource = resolve(sourcePath)
|
||||
|
||||
const originalName = basename(resolvedSource)
|
||||
try {
|
||||
assertSafeRemotePathSegment(originalName, remotePathFlavor)
|
||||
} catch (error) {
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'failed',
|
||||
reason: errorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
const classified = await classifyImportSource(sourcePath, resolvedSource)
|
||||
if ('rejection' in classified) {
|
||||
return classified.rejection
|
||||
}
|
||||
const sourceStat = classified.stat
|
||||
const isDir = sourceStat.isDirectory()
|
||||
|
||||
let createdDestDir: string | null = null
|
||||
try {
|
||||
const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null
|
||||
if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) {
|
||||
return { sourcePath, status: 'skipped', reason: 'symlink' }
|
||||
}
|
||||
|
||||
// Why: local inspection can outlive a HUB SSH session; revalidate before the first remote write.
|
||||
assertCurrent?.()
|
||||
const finalName = await deconflictCopyName(originalName, async (n) => {
|
||||
assertCurrent?.()
|
||||
return (await remotePathExists(provider, `${destDir}/${n}`)) || reservedNames.has(n)
|
||||
})
|
||||
const destPath = `${destDir}/${finalName}`
|
||||
const renamed = finalName !== originalName
|
||||
|
||||
if (isDir) {
|
||||
assertCurrent?.()
|
||||
await provider.createDirNoClobber(destPath)
|
||||
createdDestDir = destPath
|
||||
await uploadSshImportDirectory(
|
||||
provider,
|
||||
uploadSession,
|
||||
resolvedSource,
|
||||
destPath,
|
||||
rootRealPath!,
|
||||
remotePathFlavor,
|
||||
assertCurrent
|
||||
)
|
||||
} else {
|
||||
assertCurrent?.()
|
||||
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'imported',
|
||||
destPath,
|
||||
kind: isDir ? 'directory' : 'file',
|
||||
renamed
|
||||
}
|
||||
} catch (error) {
|
||||
if (createdDestDir) {
|
||||
// Why: local directory imports roll back partial output; SSH imports
|
||||
// should not leave the no-clobber root after a nested upload failure.
|
||||
try {
|
||||
assertCurrent?.()
|
||||
await provider.deletePath(createdDestDir, true)
|
||||
} catch {
|
||||
// Best effort; a replacement session must never inherit cleanup from the retired owner.
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'failed',
|
||||
reason: errorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDropStagingDir(
|
||||
provider: IFilesystemProvider,
|
||||
destDir: string,
|
||||
assertCurrent?: () => void
|
||||
): Promise<void> {
|
||||
const parent = posix.dirname(destDir)
|
||||
assertCurrent?.()
|
||||
await provider.createDir(parent)
|
||||
const gitignorePath = `${parent}/.gitignore`
|
||||
assertCurrent?.()
|
||||
if (!(await remotePathExists(provider, gitignorePath))) {
|
||||
assertCurrent?.()
|
||||
await provider.writeFile(gitignorePath, '*\n!.gitignore\n')
|
||||
}
|
||||
assertCurrent?.()
|
||||
await provider.createDir(destDir)
|
||||
}
|
||||
@@ -14,10 +14,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -31,10 +27,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
|
||||
@@ -34,10 +34,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -51,10 +47,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
|
||||
@@ -29,10 +29,6 @@ vi.mock('fs/promises', () => ({
|
||||
}))
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
resetSshConnectionGenerations,
|
||||
setSshConnectionGeneration
|
||||
@@ -346,107 +342,40 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
|
||||
})
|
||||
|
||||
it('routes rename through the SSH no-clobber filesystem provider when a connection is present', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
it('rejects direct SSH rename without target-bound generation provenance', async () => {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
})
|
||||
|
||||
try {
|
||||
await handlers.get('fs:rename')!(null, {
|
||||
it('rejects equal-generation provenance for another direct SSH target', async () => {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-b',
|
||||
expectedSshTargetId: 'ssh-a',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
})
|
||||
|
||||
it('rejects stale generation provenance for a direct SSH target', async () => {
|
||||
setSshConnectionGeneration('ssh-1', 8)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
expectedSshConnectionGeneration: 7
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).toHaveBeenCalledWith('/home/me/repo/old.ts', '/home/me/repo/new.ts')
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates SSH no-clobber rename failures', async () => {
|
||||
const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists'))
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
).rejects.toThrow('destination exists')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects direct SSH rename without target-bound generation provenance', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects equal-generation provenance for another direct SSH target', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-b', { renameNoClobber } as never)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-b',
|
||||
expectedSshTargetId: 'ssh-a',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-b')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects stale generation provenance for a direct SSH target', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
|
||||
setSshConnectionGeneration('ssh-1', 8)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:rename')!(null, {
|
||||
oldPath: '/home/me/repo/old.ts',
|
||||
newPath: '/home/me/repo/new.ts',
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 7
|
||||
})
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
})
|
||||
|
||||
it('rejects stale SSH provenance when a direct mutation resolves local', async () => {
|
||||
@@ -513,26 +442,6 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number))
|
||||
})
|
||||
|
||||
it('routes copy through the SSH filesystem provider when a connection is present', async () => {
|
||||
const copy = vi.fn().mockResolvedValue(undefined)
|
||||
registerSshFilesystemProvider('ssh-1', { copy } as never)
|
||||
|
||||
try {
|
||||
await handlers.get('fs:copy')!(null, {
|
||||
sourcePath: '/home/me/repo/source.ts',
|
||||
destinationPath: '/home/me/repo/source copy.ts',
|
||||
connectionId: 'ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 0
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts')
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Edge cases ─────────────────────────────────────────────────
|
||||
|
||||
it('propagates non-ENOENT lstat errors in assertNotExists', async () => {
|
||||
|
||||
@@ -9,16 +9,14 @@ import {
|
||||
resolveLocalRequestPath
|
||||
} from './local-file-access-resolution'
|
||||
import type { LocalFileAccess } from '../../shared/local-file-access'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
|
||||
import { importExternalPathsSsh } from './filesystem-import-ssh'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
|
||||
import { assertNotExists, rethrowWithUserMessage } from './filesystem-create-path-guards'
|
||||
import type {
|
||||
ImportItemResult,
|
||||
ImportSkipReason,
|
||||
ResolveDroppedPathsResult,
|
||||
StagedExternalImportSource
|
||||
} from '../../shared/filesystem-import-result-types'
|
||||
@@ -51,8 +49,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.createFile(args.filePath)
|
||||
throw new SshHostNotServedError(args.connectionId)
|
||||
}
|
||||
const filePath = await resolveDesktopAuthorizedPath(args.filePath, store)
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
@@ -78,8 +75,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.createDir(args.dirPath)
|
||||
throw new SshHostNotServedError(args.connectionId)
|
||||
}
|
||||
const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store)
|
||||
await assertNotExists(dirPath)
|
||||
@@ -108,8 +104,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.renameNoClobber(args.oldPath, args.newPath)
|
||||
throw new SshHostNotServedError(args.connectionId)
|
||||
}
|
||||
// Why: rename() operates on directory entries, not file contents. If
|
||||
// oldPath is a symlink, we must rename the link itself rather than
|
||||
@@ -145,8 +140,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return provider.copy(args.sourcePath, args.destinationPath)
|
||||
throw new SshHostNotServedError(args.connectionId)
|
||||
}
|
||||
const sourcePath = await resolveDesktopAuthorizedPath(args.sourcePath, store, {
|
||||
preserveSymlink: true
|
||||
@@ -180,22 +174,11 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
if (args.connectionId) {
|
||||
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, {
|
||||
ensureDir: args.ensureDir,
|
||||
assertCurrent: () =>
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
})
|
||||
throw new SshHostNotServedError(args.connectionId)
|
||||
}
|
||||
|
||||
// Why: destDir must be authorized before any copy work begins. If the
|
||||
// destination is outside allowed roots, the entire import fails.
|
||||
// This only applies to local imports — remote paths are authorized by
|
||||
// the SSH connection boundary (see importExternalPathsSsh).
|
||||
// An image inserted into a document the user opened lands in that document's own folder.
|
||||
const resolvedDest = await resolveLocalRequestPath(
|
||||
args.destDir,
|
||||
@@ -275,11 +258,9 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
)
|
||||
|
||||
// Why: terminal drag-and-drop resolver. Local worktrees pass paths through
|
||||
// unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees
|
||||
// upload each path into `${worktreePath}/.orca/drops/` and return remote
|
||||
// paths the remote agent can read. Kept as a separate IPC from
|
||||
// fs:importExternalPaths because terminal semantics differ from the
|
||||
// explorer's "copy into user-picked destDir". See docs/terminal-drop-ssh.md.
|
||||
// unchanged (reference-in-place; preserves zero-latency drop). Kept as a
|
||||
// separate IPC from fs:importExternalPaths because terminal semantics differ
|
||||
// from the explorer's "copy into user-picked destDir".
|
||||
ipcMain.handle(
|
||||
'fs:resolveDroppedPathsForAgent',
|
||||
async (
|
||||
@@ -305,32 +286,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
failed: []
|
||||
}
|
||||
}
|
||||
const worktreePath = args.worktreePath.replace(/\/+$/, '')
|
||||
const destDir = `${worktreePath}/.orca/drops`
|
||||
const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, {
|
||||
ensureDir: true,
|
||||
assertCurrent: () =>
|
||||
assertSshMutationExpectation(
|
||||
args.connectionId,
|
||||
args.expectedSshTargetId,
|
||||
args.expectedSshConnectionGeneration,
|
||||
args.expectedExecutionHostId
|
||||
)
|
||||
})
|
||||
const resolvedPaths: string[] = []
|
||||
const skipped: { sourcePath: string; reason: ImportSkipReason }[] = []
|
||||
const failed: { sourcePath: string; reason: string }[] = []
|
||||
// Iterate in input order so injected paths align with the user's drop order.
|
||||
for (const r of results) {
|
||||
if (r.status === 'imported') {
|
||||
resolvedPaths.push(r.destPath)
|
||||
} else if (r.status === 'skipped') {
|
||||
skipped.push({ sourcePath: r.sourcePath, reason: r.reason })
|
||||
} else {
|
||||
failed.push({ sourcePath: r.sourcePath, reason: r.reason })
|
||||
}
|
||||
}
|
||||
return { resolvedPaths, skipped, failed }
|
||||
throw new SshHostNotServedError(args.connectionId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,10 +24,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -41,10 +37,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
|
||||
@@ -54,10 +54,8 @@ export const getPullRequestDraftContextMock: IpcMock = vi.fn()
|
||||
export const resolveHostedReviewBodyForGenerationMock: IpcMock = vi.fn()
|
||||
export const loadPullRequestLinkedIssueMock: IpcMock = vi.fn()
|
||||
export const getSshFilesystemProviderMock: IpcMock = vi.fn()
|
||||
export const getSshGitProviderMock: IpcMock = vi.fn()
|
||||
export const tryDeleteWslUncPathMock: IpcMock = vi.fn()
|
||||
export const recordCrashBreadcrumbMock: IpcMock = vi.fn()
|
||||
export const promoteLocalDownloadedFolderMock: IpcMock = vi.fn()
|
||||
|
||||
export const electronMock = {
|
||||
app: { getPath: () => '/orca-test-user-data' },
|
||||
@@ -83,10 +81,6 @@ export const wslUncDeleteMock = { tryDeleteWslUncPath: tryDeleteWslUncPathMock }
|
||||
|
||||
export const crashBreadcrumbMock = { recordCrashBreadcrumb: recordCrashBreadcrumbMock }
|
||||
|
||||
export const folderPromotionMock = {
|
||||
promoteLocalDownloadedFolder: promoteLocalDownloadedFolderMock
|
||||
}
|
||||
|
||||
export const gitStatusModuleMock = {
|
||||
commitChanges: commitChangesMock,
|
||||
getStatus: getStatusMock,
|
||||
@@ -114,25 +108,8 @@ export const gitWorktreeMock = {
|
||||
listWorktreesStrict: listWorktreesMock
|
||||
}
|
||||
|
||||
const PROVIDER_UNAVAILABLE_MESSAGE =
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
|
||||
export const sshFilesystemDispatchMock = {
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
requireSshFilesystemProvider: (connectionId: string) => {
|
||||
const provider = getSshFilesystemProviderMock(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
export const sshGitDispatchMock = {
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
sshGitProviderMissingError: () => new Error(PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock
|
||||
}
|
||||
|
||||
export const textGenerationModuleMock = {
|
||||
@@ -227,12 +204,10 @@ const ALL_MOCKS = [
|
||||
fsPromisesMock,
|
||||
wslUncDeleteMock,
|
||||
crashBreadcrumbMock,
|
||||
folderPromotionMock,
|
||||
gitStatusModuleMock,
|
||||
gitIgnoredPathsMock,
|
||||
gitWorktreeMock,
|
||||
sshFilesystemDispatchMock,
|
||||
sshGitDispatchMock,
|
||||
textGenerationModuleMock,
|
||||
pullRequestContextMock,
|
||||
pullRequestTemplateMock,
|
||||
@@ -296,12 +271,14 @@ export function resetFilesystemIpcMocks(): void {
|
||||
trashItemMock.mockResolvedValue(undefined)
|
||||
// Default: not a WSL UNC path, so deletePath falls through to shell.trashItem.
|
||||
tryDeleteWslUncPathMock.mockResolvedValue(false)
|
||||
promoteLocalDownloadedFolderMock.mockResolvedValue(undefined)
|
||||
showSaveDialogMock.mockResolvedValue({ canceled: true })
|
||||
showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] })
|
||||
fromWebContentsMock.mockReturnValue(null)
|
||||
getSshGitProviderMock.mockReturnValue(null)
|
||||
statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 })
|
||||
statMock.mockResolvedValue({
|
||||
size: 10,
|
||||
isDirectory: () => false,
|
||||
mtimeMs: 123
|
||||
})
|
||||
renameMock.mockResolvedValue(undefined)
|
||||
rmMock.mockResolvedValue(undefined)
|
||||
openMock.mockResolvedValue(localFileHandleMock(Buffer.from('a'.repeat(10))))
|
||||
|
||||
@@ -31,10 +31,6 @@ vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
@@ -48,10 +44,6 @@ vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
|
||||
@@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto'
|
||||
import { open, writeFile } from 'node:fs/promises'
|
||||
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
|
||||
import { sanitizeLocalDownloadFilename } from '../../local-download-filename'
|
||||
import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder'
|
||||
import { abortWhenRendererGone } from '../renderer-lifetime-abort'
|
||||
import type { FilesystemHandlerContext } from './filesystem-handler-context'
|
||||
import {
|
||||
@@ -36,7 +35,14 @@ export function registerFilesystemDownloadHandlers(context: FilesystemHandlerCon
|
||||
}
|
||||
)
|
||||
|
||||
registerFilesystemDownloadFolderHandlers()
|
||||
ipcMain.handle(
|
||||
'fs:downloadFolder',
|
||||
async (_event, args: { dirPath?: string; connectionId?: string }): Promise<never> => {
|
||||
validateRequiredString(args?.dirPath, 'dirPath')
|
||||
const connectionId = validateRequiredString(args?.connectionId, 'connectionId')
|
||||
throw new SshHostNotServedError(connectionId)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'fs:saveDownloadedFile',
|
||||
|
||||
@@ -12,7 +12,6 @@ vi.mock('../registered-worktree-roots-cache', () => ({
|
||||
resolveRegisteredWorktreePath: async (path: string) => path
|
||||
}))
|
||||
vi.mock('../filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn() }))
|
||||
vi.mock('../../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() }))
|
||||
import { getRepoForSourceControlAi } from './filesystem-source-control-ai-targets'
|
||||
|
||||
const repo: Repo = {
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { Store } from '../persistence'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
gitExec: vi.fn(),
|
||||
getProvider: vi.fn(),
|
||||
getProviderGeneration: vi.fn(),
|
||||
resolveRegisteredPath: vi.fn(),
|
||||
getLocalRepo: vi.fn(),
|
||||
getLocalOptions: vi.fn(),
|
||||
@@ -12,11 +10,6 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.gitExec }))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getProvider,
|
||||
getSshGitProviderGeneration: mocks.getProviderGeneration,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable'
|
||||
}))
|
||||
vi.mock('./registered-worktree-roots-cache', () => ({
|
||||
resolveRegisteredWorktreePath: mocks.resolveRegisteredPath
|
||||
}))
|
||||
@@ -34,7 +27,6 @@ import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
describe('applyGitStatusUpstreamRefWatchRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getProviderGeneration.mockReturnValue(0)
|
||||
mocks.resolveRegisteredPath.mockResolvedValue('/resolved/repo')
|
||||
mocks.getLocalOptions.mockReturnValue({})
|
||||
mocks.setWatch.mockImplementation(
|
||||
@@ -68,11 +60,7 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => {
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
it('binds the SSH watch but refuses to resolve on an SSH host', async () => {
|
||||
await expect(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SSH refusal throws before any store read.
|
||||
applyGitStatusUpstreamRefWatchRequest({} as Store, {
|
||||
@@ -86,10 +74,9 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => {
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
|
||||
expect(mocks.setWatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'ssh-1', providerGeneration: 7 }),
|
||||
expect.objectContaining({ connectionId: 'ssh-1' }),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(exec).not.toHaveBeenCalled()
|
||||
expect(mocks.gitExec).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { resolveGitStatusUpstreamRef } from '../git/status-upstream-ref'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache'
|
||||
import {
|
||||
getLocalGitOptionsForRepo,
|
||||
@@ -12,10 +11,7 @@ import type { GitStatusRefBindingRequest } from './worktree-git-status-ref-watch
|
||||
import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch'
|
||||
import { getConnectionExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export type GitStatusUpstreamRefWatchRequest = Omit<
|
||||
GitStatusRefBindingRequest,
|
||||
'providerGeneration'
|
||||
>
|
||||
export type GitStatusUpstreamRefWatchRequest = GitStatusRefBindingRequest
|
||||
|
||||
const UPSTREAM_REF_RESOLUTION_TIMEOUT_MS = 15_000
|
||||
|
||||
@@ -27,34 +23,28 @@ export function applyGitStatusUpstreamRefWatchRequest(
|
||||
store: Store,
|
||||
args: GitStatusUpstreamRefWatchRequest
|
||||
): Promise<void> {
|
||||
const providerGeneration = args.connectionId
|
||||
? getSshGitProviderGeneration(args.connectionId)
|
||||
: undefined
|
||||
return setWorktreeGitStatusRefWatch(
|
||||
{ ...args, ...(providerGeneration !== undefined ? { providerGeneration } : {}) },
|
||||
async (bindingSignal) => {
|
||||
if (!args.branch || !args.upstreamName) {
|
||||
return undefined
|
||||
}
|
||||
const signal = boundedSignal(bindingSignal)
|
||||
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
|
||||
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)
|
||||
const gitOptions = getLocalGitOptionsForRepo(store, repo)
|
||||
return resolveGitStatusUpstreamRef(
|
||||
(gitArgs, cwd, requestSignal) =>
|
||||
gitExecFileAsync(gitArgs, {
|
||||
cwd,
|
||||
signal: requestSignal,
|
||||
timeout: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS,
|
||||
...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {})
|
||||
}),
|
||||
worktreePath,
|
||||
args.branch,
|
||||
args.upstreamName,
|
||||
signal
|
||||
)
|
||||
return setWorktreeGitStatusRefWatch(args, async (bindingSignal) => {
|
||||
if (!args.branch || !args.upstreamName) {
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
const signal = boundedSignal(bindingSignal)
|
||||
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
|
||||
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)
|
||||
const gitOptions = getLocalGitOptionsForRepo(store, repo)
|
||||
return resolveGitStatusUpstreamRef(
|
||||
(gitArgs, cwd, requestSignal) =>
|
||||
gitExecFileAsync(gitArgs, {
|
||||
cwd,
|
||||
signal: requestSignal,
|
||||
timeout: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS,
|
||||
...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {})
|
||||
}),
|
||||
worktreePath,
|
||||
args.branch,
|
||||
args.upstreamName,
|
||||
signal
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ import { getStoredRepoSshConnectionId } from '../../repo-execution-host'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks'
|
||||
import { isENOENT } from '../filesystem-path-containment'
|
||||
import { hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
|
||||
@@ -30,27 +27,13 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v
|
||||
return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: !result.isBinary,
|
||||
hooks: result.isBinary ? null : parseOrcaYaml(result.content),
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: isENOENT(error) ? 'ok' : 'error',
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
if (getStoredRepoSshConnectionId(repo)) {
|
||||
// Why: this process serves no SSH filesystem.
|
||||
return {
|
||||
status: 'error',
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@ import { ipcMain } from 'electron'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||
import { isENOENT } from '../filesystem-path-containment'
|
||||
import { parseOrcaYaml } from '../../hooks'
|
||||
import {
|
||||
isIssueCommandIgnoredByGit,
|
||||
readIssueCommand,
|
||||
writeIssueCommand
|
||||
} from '../../issue-command-file'
|
||||
import { readIssueCommand, writeIssueCommand } from '../../issue-command-file'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
|
||||
@@ -35,52 +28,14 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v
|
||||
}
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (connectionId) {
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return {
|
||||
status: 'error',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: issueCommandPath,
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
|
||||
let status: 'ok' | 'error' = 'ok'
|
||||
let localContent: string | null = null
|
||||
let sharedContent: string | null = null
|
||||
try {
|
||||
const result = await fsProvider.readFile(issueCommandPath)
|
||||
localContent = result.isBinary ? null : result.content.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
sharedContent = result.isBinary
|
||||
? null
|
||||
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
}
|
||||
}
|
||||
const effectiveContent = localContent ?? sharedContent
|
||||
// Why: this process serves no SSH filesystem.
|
||||
return {
|
||||
status: localContent ? 'ok' : status,
|
||||
localContent,
|
||||
sharedContent,
|
||||
effectiveContent,
|
||||
localFilePath: issueCommandPath,
|
||||
source: localContent
|
||||
? ('local' as const)
|
||||
: sharedContent
|
||||
? ('shared' as const)
|
||||
: ('none' as const)
|
||||
status: 'error',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: joinWorktreeRelativePath(repo.path, '.orca/issue-command'),
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
return readIssueCommand(repo.path)
|
||||
@@ -94,44 +49,8 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return
|
||||
}
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (connectionId) {
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
throw new Error(
|
||||
'Remote filesystem unavailable. Reconnect the SSH target before retrying.'
|
||||
)
|
||||
}
|
||||
const trimmed = args.content.trim()
|
||||
if (!trimmed) {
|
||||
await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca'))
|
||||
if (await isIssueCommandIgnoredByGit(repo.path, connectionId)) {
|
||||
await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`)
|
||||
return
|
||||
}
|
||||
const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore')
|
||||
try {
|
||||
const result = await fsProvider.readFile(gitignorePath)
|
||||
if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) {
|
||||
const separator = result.content.endsWith('\n') ? '' : '\n'
|
||||
await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
await fsProvider.writeFile(gitignorePath, '.orca\n')
|
||||
}
|
||||
await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`)
|
||||
return
|
||||
if (getStoredRepoSshConnectionId(repo)) {
|
||||
throw new Error('Remote filesystem unavailable. Reconnect the SSH target before retrying.')
|
||||
}
|
||||
await writeIssueCommand(repo.path, args.content, () =>
|
||||
getLocalProjectWorktreeGitOptions(store, repo)
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports'
|
||||
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { isENOENT } from '../filesystem-path-containment'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
@@ -17,27 +16,14 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex
|
||||
'hooks:inspectSetupScriptImports',
|
||||
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
// Why: this process serves no SSH filesystem.
|
||||
if (!repo || isFolderRepo(repo) || getStoredRepoSshConnectionId(repo)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return inspectSetupScriptImportCandidates(
|
||||
async (relativePath) => {
|
||||
const filePath = joinWorktreeRelativePath(repo.path, relativePath)
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(filePath)
|
||||
return result.isBinary ? null : result.content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
@@ -50,20 +36,6 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex
|
||||
{
|
||||
fileExists: async (relativePath) => {
|
||||
const filePath = joinWorktreeRelativePath(repo.path, relativePath)
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const fileStat = await fsProvider.stat(filePath)
|
||||
return fileStat.type !== 'directory'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
return !fileStat.isDirectory()
|
||||
|
||||
@@ -10,13 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
localRead: vi.fn(),
|
||||
localWrite: vi.fn(),
|
||||
localFileRead: vi.fn(),
|
||||
localStat: vi.fn(),
|
||||
remoteRead: vi.fn(),
|
||||
remoteWrite: vi.fn(),
|
||||
remoteStat: vi.fn(),
|
||||
remoteMkdir: vi.fn(),
|
||||
provider: vi.fn(),
|
||||
ignored: vi.fn()
|
||||
localStat: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
@@ -33,16 +27,15 @@ vi.mock('../../hooks', () => ({
|
||||
}))
|
||||
vi.mock('../../issue-command-file', () => ({
|
||||
readIssueCommand: mocks.localRead,
|
||||
writeIssueCommand: mocks.localWrite,
|
||||
isIssueCommandIgnoredByGit: mocks.ignored
|
||||
writeIssueCommand: mocks.localWrite
|
||||
}))
|
||||
vi.mock('../../project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: () => ({})
|
||||
}))
|
||||
vi.mock('../../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: mocks.provider
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: mocks.localFileRead,
|
||||
stat: mocks.localStat
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => ({ readFile: mocks.localFileRead, stat: mocks.localStat }))
|
||||
vi.mock('../../../shared/setup-script-imports', () => ({
|
||||
inspectSetupScriptImportCandidates: async (
|
||||
read: (path: string) => Promise<unknown>,
|
||||
@@ -67,22 +60,11 @@ import { registerWorktreeHookInspectionHandler } from './register-worktree-hook-
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.handlers.clear()
|
||||
mocks.remoteRead.mockResolvedValue({ content: 'remote command', isBinary: false })
|
||||
mocks.remoteWrite.mockResolvedValue(undefined)
|
||||
mocks.remoteMkdir.mockResolvedValue(undefined)
|
||||
mocks.remoteStat.mockResolvedValue({ type: 'file' })
|
||||
mocks.ignored.mockResolvedValue(true)
|
||||
mocks.localRead.mockResolvedValue({ localContent: 'wrong host' })
|
||||
mocks.localWrite.mockResolvedValue(undefined)
|
||||
mocks.localFileRead.mockResolvedValue('wrong host')
|
||||
mocks.localStat.mockResolvedValue({ isDirectory: () => false })
|
||||
mocks.hasHooks.mockReturnValue(false)
|
||||
mocks.provider.mockReturnValue({
|
||||
readFile: mocks.remoteRead,
|
||||
writeFile: mocks.remoteWrite,
|
||||
createDir: mocks.remoteMkdir,
|
||||
stat: mocks.remoteStat
|
||||
})
|
||||
})
|
||||
|
||||
const owners = [
|
||||
@@ -151,21 +133,14 @@ const localCalls = [
|
||||
mocks.effectiveHooks
|
||||
]
|
||||
|
||||
// Why: this process serves no SSH filesystem, so an SSH owner is neither read remotely nor locally.
|
||||
function expectRoute(target: string | null): void {
|
||||
if (target) {
|
||||
expect(mocks.provider).toHaveBeenCalledWith(target)
|
||||
for (const [connectionId] of mocks.provider.mock.calls) {
|
||||
expect(connectionId).toBe(target)
|
||||
}
|
||||
for (const method of localCalls) {
|
||||
expect(method).not.toHaveBeenCalled()
|
||||
}
|
||||
} else {
|
||||
expect(mocks.provider).not.toHaveBeenCalled()
|
||||
expect(localCalls.some((method) => method.mock.calls.length > 0)).toBe(true)
|
||||
expect(mocks.remoteRead).not.toHaveBeenCalled()
|
||||
expect(mocks.remoteWrite).not.toHaveBeenCalled()
|
||||
expect(mocks.remoteStat).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,19 +148,17 @@ describe.each(owners)('desktop hooks: $label', ({ fields, target }) => {
|
||||
it.each(channels)('routes %s to the stored owner', async (channel) => {
|
||||
const repo = makeRepo(fields)
|
||||
register(repo)
|
||||
await mocks.handlers.get(channel)!(null, {
|
||||
repoId: repo.id,
|
||||
hostId: getRepoExecutionHostId(repo),
|
||||
content: 'save'
|
||||
})
|
||||
const result = Promise.resolve(
|
||||
mocks.handlers.get(channel)!(null, {
|
||||
repoId: repo.id,
|
||||
hostId: getRepoExecutionHostId(repo),
|
||||
content: 'save'
|
||||
})
|
||||
)
|
||||
await (channel === 'hooks:writeIssueCommand' && target
|
||||
? expect(result).rejects.toThrow('Remote filesystem unavailable')
|
||||
: result)
|
||||
expectRoute(target)
|
||||
if (channel === 'hooks:writeIssueCommand' && target) {
|
||||
expect(mocks.ignored).toHaveBeenCalledWith(repo.path, target)
|
||||
expect(mocks.remoteWrite).toHaveBeenCalledWith(
|
||||
'/remote/fixture/.orca/issue-command',
|
||||
'save\n'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -210,16 +183,7 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => {
|
||||
} else {
|
||||
await issue.write(repo.id, 'save')
|
||||
}
|
||||
if (target) {
|
||||
// The runtime serves no SSH filesystem: an SSH owner is neither read remotely nor locally.
|
||||
expect(mocks.provider).not.toHaveBeenCalled()
|
||||
expect(mocks.remoteWrite).not.toHaveBeenCalled()
|
||||
for (const method of localCalls) {
|
||||
expect(method).not.toHaveBeenCalled()
|
||||
}
|
||||
} else {
|
||||
expectRoute(target)
|
||||
}
|
||||
expectRoute(target)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -227,7 +191,6 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => {
|
||||
it('refuses an unreachable canonical SSH write without touching local files', async () => {
|
||||
const repo = makeRepo({ executionHostId: 'ssh:host-a' })
|
||||
register(repo)
|
||||
mocks.provider.mockReturnValue(null)
|
||||
await expect(
|
||||
mocks.handlers.get('hooks:writeIssueCommand')!(null, {
|
||||
repoId: repo.id,
|
||||
@@ -246,7 +209,6 @@ it.each(channels)('keeps folder repositories out of %s', async (channel) => {
|
||||
hostId: 'ssh:host-a',
|
||||
content: 'save'
|
||||
})
|
||||
expect(mocks.provider).not.toHaveBeenCalled()
|
||||
expect(mocks.hasHooks).not.toHaveBeenCalled()
|
||||
expect(mocks.localRead).not.toHaveBeenCalled()
|
||||
expect(mocks.localWrite).not.toHaveBeenCalled()
|
||||
|
||||
@@ -32,9 +32,12 @@ vi.mock('../ssh/orcad-migration-delta-move', () => ({
|
||||
vi.mock('../ssh/orcad-runtime-conversion-wiring', () => ({
|
||||
orcadMigrationDestinationFor: () => ({})
|
||||
}))
|
||||
vi.mock('../ssh/orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: vi.fn() }))
|
||||
vi.mock('../ssh/ssh-target-registry', () => ({ hasRegisteredDirectSshAuthority: () => false }))
|
||||
vi.mock('./ssh-session-teardown', () => ({ disconnectRegisteredSshTarget: vi.fn() }))
|
||||
vi.mock('../ssh/orcad-managed-tunnel', () => ({
|
||||
ensureOrcadManagedTunnel: vi.fn()
|
||||
}))
|
||||
vi.mock('./ssh-session-teardown', () => ({
|
||||
disconnectRegisteredSshTarget: vi.fn()
|
||||
}))
|
||||
vi.mock('./runtime-environment-managed-tunnel', () => ({
|
||||
publishResolvedChangedHostStatus: mocks.publish
|
||||
}))
|
||||
|
||||
@@ -11,9 +11,7 @@ import { keepOrcadServerVersion, runOrcadDeltaMove } from '../ssh/orcad-migratio
|
||||
import { planOrcadDeltaMove } from '../ssh/orcad-migration-delta-plan'
|
||||
import { censusHostRelayTerminalsFor } from '../ssh/ssh-host-relay-census-for-target'
|
||||
import { orcadMigrationDestinationFor } from '../ssh/orcad-runtime-conversion-wiring'
|
||||
import { hasRegisteredDirectSshAuthority } from '../ssh/ssh-target-registry'
|
||||
import { requiredString } from './orcad-runtime-lifecycle-handlers'
|
||||
import { disconnectRegisteredSshTarget } from './ssh-session-teardown'
|
||||
import { publishResolvedChangedHostStatus } from './runtime-environment-managed-tunnel'
|
||||
import { runTargetLifecycle } from './ssh-target-lifecycle-queue'
|
||||
|
||||
@@ -46,11 +44,6 @@ export function registerOrcadDeltaMoveHandlers(getUserDataPath: () => string): v
|
||||
environment,
|
||||
destination: orcadMigrationDestinationFor(environment),
|
||||
censusHost: censusHostRelayTerminalsFor(target),
|
||||
releaseDirectSession: async (targetId) => {
|
||||
if (hasRegisteredDirectSshAuthority(targetId)) {
|
||||
await disconnectRegisteredSshTarget(targetId)
|
||||
}
|
||||
},
|
||||
ensureTunnel: async () => {
|
||||
await ensureOrcadManagedTunnel(userDataPath, environment.id)
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
getFolderWorkspacePathStatus
|
||||
} from '../../../project-groups/folder-workspace-path-status'
|
||||
import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch'
|
||||
|
||||
export function assertFolderWorkspacePtyPathUsable(
|
||||
store: Store | undefined,
|
||||
@@ -20,11 +19,10 @@ export function assertFolderWorkspacePtyPathUsable(
|
||||
if (!store || workspaceScope?.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
return getFolderWorkspacePathStatus(
|
||||
store,
|
||||
{ scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId },
|
||||
{ getSshFilesystemProvider }
|
||||
).then(assertFolderWorkspacePathUsable)
|
||||
return getFolderWorkspacePathStatus(store, {
|
||||
scope: 'folder-workspace',
|
||||
folderWorkspaceId: workspaceScope.folderWorkspaceId
|
||||
}).then(assertFolderWorkspacePathUsable)
|
||||
}
|
||||
|
||||
export function resolvePtySpawnStartupCwd(
|
||||
|
||||
@@ -14,9 +14,9 @@ vi.mock('./preflight-command-exec', () => ({ isCommandOnPath: vi.fn() }))
|
||||
vi.mock('./local-agent-install-dir-detection', () => ({
|
||||
detectCommandsInInstallDirs: () => new Set()
|
||||
}))
|
||||
vi.mock('./preflight-wsl-agent-detection', () => ({ detectWslCommandsOnPath: vi.fn() }))
|
||||
const { mux } = vi.hoisted(() => ({ mux: vi.fn() }))
|
||||
vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mux }))
|
||||
vi.mock('./preflight-wsl-agent-detection', () => ({
|
||||
detectWslCommandsOnPath: vi.fn()
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof FsPromises>()),
|
||||
stat: vi.fn(async () => ({ isDirectory: () => true }))
|
||||
@@ -76,7 +76,6 @@ describe('desktop Qoder execution-host selection', () => {
|
||||
beforeEach(() => {
|
||||
nativeProbe.mockReset().mockImplementation(async (cmd) => cmd === 'qodercli')
|
||||
wslProbe.mockReset().mockResolvedValue(new Set(['qoder']))
|
||||
mux.mockReset()
|
||||
})
|
||||
async function spawn(args: Partial<PtySpawnIpcArgs> = {}, store?: Store) {
|
||||
const providerSpawn = setupDaemonAdapter()
|
||||
@@ -171,7 +170,6 @@ describe('desktop Qoder execution-host selection', () => {
|
||||
expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ command: args.command }))
|
||||
expect(nativeProbe).not.toHaveBeenCalled()
|
||||
expect(wslProbe).not.toHaveBeenCalled()
|
||||
expect(mux).not.toHaveBeenCalled()
|
||||
})
|
||||
it('uses the selected project WSL distro rather than installed native legacy', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
|
||||
@@ -70,9 +70,6 @@ vi.mock('../worktree-root-preparation', () => ({
|
||||
prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() }))
|
||||
vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() }))
|
||||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
|
||||
const MAIN_CHECKOUT = '/Users/dev/projects/orca'
|
||||
|
||||
@@ -87,14 +87,6 @@ vi.mock('../worktree-root-preparation', () => ({
|
||||
prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveMultiplexer: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
|
||||
type CreateArgs = { parentPath: string; name: string; kind: 'git' | 'folder' }
|
||||
|
||||
@@ -21,10 +21,6 @@ vi.mock('./registered-worktree-roots-cache', () =>
|
||||
vi.mock('../worktree-root-preparation', () =>
|
||||
moduleMocks.worktreeRootPreparationModuleMock(reposMocks)
|
||||
)
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () =>
|
||||
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
|
||||
)
|
||||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user