refactor(ipc): filesystem and repo handlers are local or runtime only (#27609)

* refactor(ipc): filesystem and repo handlers are local or runtime only

* test(ipc): drop two casts in the rewritten listing and import tests

* fix(providers): keep the default error name so IPC callers see the old text

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 15:41:56 -07:00
committed by GitHub
co-authored by m4air
parent c9730df4c8
commit df2f2a72de
73 changed files with 443 additions and 4112 deletions
+4 -20
View File
@@ -4280,7 +4280,7 @@
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/watched-worktree-catalog-notification.test.ts src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts src/main/window/attach-main-window-services.test.ts",
"ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-external-worktree-discovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot"
],
"testFiles": [
@@ -4296,7 +4296,6 @@
"src/main/window/attach-main-window-services.test.ts",
"tests/e2e/paired-external-worktree-discovery.spec.ts",
"src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts",
"src/main/ipc/repos-remote.test.ts",
"src/main/ssh/ssh-connection-generation.test.ts",
"src/main/ssh/ssh-provider-authority.test.ts"
],
@@ -4396,13 +4395,6 @@
"the paired client's main-process shared-control transport keeps one connection epoch while converging and compares Windows paths case-insensitively"
]
},
{
"file": "src/main/ipc/repos-remote.test.ts",
"assertions": [
"a host-qualified catalog rejects contradictory executionHostId and connectionId provenance without returning rows",
"local, sibling SSH, and runtime rows remain excluded from the exact direct SSH catalog"
]
},
{
"file": "src/main/ssh/ssh-connection-generation.test.ts",
"assertions": [
@@ -4432,10 +4424,10 @@
"date": "2026-07-27",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1.61,
"summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation."
"summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation. The third file, src/main/ipc/repos-remote.test.ts, was later deleted with the SSH repo IPC handlers."
},
{
"date": "2026-10-11",
@@ -12922,7 +12914,7 @@
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot",
"pnpm exec electron-vite build --mode e2e",
"pnpm run build:web-from-renderer",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
@@ -12966,7 +12958,6 @@
"src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts",
"src/renderer/src/hooks/direct-ssh-state-routing.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"src/main/ipc/repos-remote.test.ts",
"src/main/ipc/ssh.test.ts",
"src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts",
"src/main/ipc/worktrees-lineage-hydration.test.ts",
@@ -13133,13 +13124,6 @@
"a detached null-PTY leaf remains marked for pending activation before ownership transfer"
]
},
{
"file": "src/main/ipc/repos-remote.test.ts",
"assertions": [
"host-qualified repo catalogs require one consistent execution host and complete current SSH authority",
"contradictory, partial, mismatched, stale, and runtime-owned catalog requests fail closed"
]
},
{
"file": "src/main/ipc/ssh.test.ts",
"assertions": [
-2
View File
@@ -2672,10 +2672,8 @@
"src/main/ipc/repos-remote-base-ref-queries.test.ts": 1223,
"src/main/ipc/repos-remote-client-events.test.ts": 1788,
"src/main/ipc/repos-remote-git-username.test.ts": 1104,
"src/main/ipc/repos-remote.test.ts": 1533,
"src/main/ipc/repos-sparse-presets.test.ts": 1196,
"src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts": 1525,
"src/main/ipc/repos/remote-repo-registration.test.ts": 282,
"src/main/ipc/repos/repo-creation-git-availability.test.ts": 310,
"src/main/ipc/runtime-agent-launch-capability.test.ts": 3143,
"src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 1143,
-15
View File
@@ -1,9 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import {
clearGitCapabilityStateForTests,
getLocalGitCapabilityCache,
getSshGitCapabilityCache,
withLocalGitCapabilityCacheForExecution
} from './git-capability-state'
import {
@@ -11,9 +9,6 @@ import {
seedWslLinkedWorktreeGitRoutingForTests
} from './wsl-linked-worktree-git-routing'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cache keys providers by reference only and never calls a method on them.
const createProviderIdentity = (): SshGitProvider => ({}) as SshGitProvider
describe('Git capability execution-host state', () => {
beforeEach(() => {
clearGitCapabilityStateForTests()
@@ -46,16 +41,6 @@ describe('Git capability execution-host state', () => {
).toBe(true)
})
it('shares one SSH provider lifetime without leaking into a replacement provider', () => {
const provider = createProviderIdentity()
const replacementProvider = createProviderIdentity()
expect(getSshGitCapabilityCache(provider)).toBe(getSshGitCapabilityCache(provider))
expect(getSshGitCapabilityCache(provider)).not.toBe(
getSshGitCapabilityCache(replacementProvider)
)
})
it('uses native capability state for a prepared host-routed WSL worktree', async () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
-14
View File
@@ -1,5 +1,4 @@
import { GitCapabilityCache } from '../../shared/git-capability-cache'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
isWslLinkedWorktreeGitRoutingCandidate,
@@ -14,9 +13,6 @@ type LocalGitCapabilityTarget = {
const localCapabilitiesByExecutionHost = new Map<string, GitCapabilityCache>()
const MAX_LOCAL_GIT_CAPABILITY_HOSTS = 128
// Why: reconnecting creates a new provider, while concurrent IPC/runtime users
// of one SSH connection must share the same remote Git capability results.
let sshCapabilitiesByProvider = new WeakMap<SshGitProvider, GitCapabilityCache>()
function getLocalGitExecutionHostKey(target: LocalGitCapabilityTarget): string {
const wslDistro =
@@ -66,16 +62,6 @@ export function withLocalGitCapabilityCacheForExecution<T>(
)
}
export function getSshGitCapabilityCache(provider: SshGitProvider): GitCapabilityCache {
let cache = sshCapabilitiesByProvider.get(provider)
if (!cache) {
cache = new GitCapabilityCache()
sshCapabilitiesByProvider.set(provider, cache)
}
return cache
}
export function clearGitCapabilityStateForTests(): void {
localCapabilitiesByExecutionHost.clear()
sshCapabilitiesByProvider = new WeakMap()
}
-21
View File
@@ -1,4 +1,3 @@
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner'
import { parseHostedRemote } from './hosted-remote-url'
import { resolveDefaultBaseRefViaExec } from './repo'
@@ -82,26 +81,6 @@ function normalizeConfiguredLogin(value: string): string {
*/
export type ResolvedGitUsername = { username: string; authoritative: boolean }
export async function getSshGitUsername(
provider: SshGitProvider,
repoPath: string
): Promise<string> {
// Why: SSH targets cannot rely on the local `gh` account, and git email/name
// are author identity rather than hosted-account usernames.
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
try {
const { stdout } = await provider.exec(['config', '--get', key], repoPath)
const username = normalizeConfiguredLogin(stdout)
if (username) {
return username
}
} catch {
// Missing config keys are expected; try the next explicit username key.
}
}
return ''
}
type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean }
type GhLoginOutcome = { login: string; timedOut: boolean }
+1 -1
View File
@@ -110,7 +110,7 @@ export async function getRecentDriftSubjects(
}
/** Parse `git remote` stdout into a remote count. */
export function parseRemoteCount(stdout: string): number {
function parseRemoteCount(stdout: string): number {
return stdout.split('\n').filter((line) => line.trim().length > 0).length
}
@@ -11,7 +11,6 @@ import {
generateCommitMessageFromContextMock,
cancelGenerateCommitMessageLocalMock,
cancelGeneratePullRequestFieldsLocalMock,
getSshGitProviderMock,
resetFilesystemIpcMocks
} from './filesystem-test-harness'
@@ -65,6 +64,7 @@ vi.mock(
import { registerFilesystemHandlers } from './filesystem'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
describe('registerFilesystemHandlers', () => {
beforeEach(() => {
@@ -334,36 +334,6 @@ describe('registerFilesystemHandlers', () => {
)
})
it('enriches the SSH commit context from host meta using the remote path', async () => {
const context = { branch: 'main', stagedSummary: 'A\tremote.txt', stagedPatch: '+remote' }
const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' }
const worktreeId = 'repo-1::/remote/repo'
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getSshGitProviderMock.mockReturnValue({
getStagedCommitContext: vi.fn().mockResolvedValue(context),
executeCommitMessagePlan: vi.fn()
})
generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Add file' })
const linkedStore = {
...store,
getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined)
}
registerFilesystemHandlers(linkedStore as never)
await handlers.get('git:generateCommitMessage')!(null, {
worktreePath: '/remote/repo',
worktreeId,
connectionId: 'conn-1'
})
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
{ ...context, linkedIssue: 77 },
params,
expect.objectContaining({ kind: 'remote' })
)
})
it('returns a sanitized error when local agent account preparation fails', async () => {
const context = {
branch: 'feature/ai',
@@ -484,71 +454,25 @@ describe('registerFilesystemHandlers', () => {
}
})
it('generates an SSH commit message using remote staged context and relay execution', async () => {
const context = {
branch: 'main',
stagedSummary: 'A\tremote.txt',
stagedPatch: '+remote'
}
const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' }
const executeCommitMessagePlan = vi.fn()
const prepareForCodexLaunch = vi.fn(() => '/managed/codex-home')
const prepareForClaudeLaunch = vi.fn()
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getSshGitProviderMock.mockReturnValue({
getStagedCommitContext: vi.fn().mockResolvedValue(context),
executeCommitMessagePlan
})
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'Add remote file'
it('refuses SSH commit generation as not served without reading local staged changes', async () => {
resolveCommitMessageSettingsMock.mockReturnValue({
ok: true,
params: { agentId: 'custom', model: '', customAgentCommand: 'agent' }
})
const resolveBaseEnvironment = vi.fn(async () => ({}))
registerFilesystemHandlers(store as never, {
prepareForCodexLaunch,
prepareForClaudeLaunch,
resolveBaseEnvironment
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: true, message: 'Add remote file' })
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'remote',
cwd: '/remote/repo',
missingBinaryLocation: 'remote PATH'
})
)
const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2]
await target.execute(
{ binary: 'agent', args: [], stdinPayload: null, label: 'agent' },
'/cwd',
1,
'commit-message'
)
expect(executeCommitMessagePlan).toHaveBeenCalledWith(
{ binary: 'agent', args: [], stdinPayload: null, label: 'agent' },
'/cwd',
1,
'commit-message'
)
expect(prepareForCodexLaunch).not.toHaveBeenCalled()
expect(prepareForClaudeLaunch).not.toHaveBeenCalled()
expect(resolveBaseEnvironment).not.toHaveBeenCalled()
).resolves.toEqual({ success: false, error: SSH_HOST_NOT_SERVED_MESSAGE })
expect(getStagedCommitContextMock).not.toHaveBeenCalled()
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
})
it('routes SSH generation cancellations to separate provider operations', async () => {
const cancelGenerateCommitMessage = vi.fn().mockResolvedValue(undefined)
getSshGitProviderMock.mockReturnValue({ cancelGenerateCommitMessage })
it('ignores SSH generation cancellations instead of cancelling local work', async () => {
registerFilesystemHandlers(store as never)
await handlers.get('git:cancelGenerateCommitMessage')!(null, {
@@ -560,12 +484,6 @@ describe('registerFilesystemHandlers', () => {
connectionId: 'conn-1'
})
expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(1, '/remote/repo', 'commit-message')
expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(
2,
'/remote/repo',
'pull-request-fields'
)
expect(cancelGenerateCommitMessageLocalMock).not.toHaveBeenCalled()
expect(cancelGeneratePullRequestFieldsLocalMock).not.toHaveBeenCalled()
})
@@ -605,25 +523,4 @@ describe('registerFilesystemHandlers', () => {
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
})
it('sanitizes SSH staged-context read failures before returning to the renderer', async () => {
resolveCommitMessageSettingsMock.mockReturnValue({
ok: true,
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
})
getSshGitProviderMock.mockReturnValue({
getStagedCommitContext: vi.fn().mockRejectedValue(new Error('fatal: /remote/secret failed'))
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' })
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
})
})
@@ -7,8 +7,6 @@ import {
WORKSPACE_DIR,
WORKTREE_FEATURE_PATH,
discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemoteMock,
getSshGitProviderMock,
resetFilesystemIpcMocks
} from './filesystem-test-harness'
@@ -267,54 +265,16 @@ describe('registerFilesystemHandlers', () => {
})
})
it('routes SSH model discovery through the remote git provider', async () => {
discoverCommitMessageModelsRemoteMock.mockResolvedValue({
success: true,
capability: {
id: 'cursor',
label: 'Cursor',
modelSource: 'dynamic',
defaultModelId: 'auto',
models: [{ id: 'auto', label: 'Auto' }]
},
models: [{ id: 'auto', label: 'Auto' }],
defaultModelId: 'auto'
})
const executeCommitMessagePlan = vi.fn()
getSshGitProviderMock.mockReturnValue({ executeCommitMessagePlan })
const storeWithOverride = {
...store,
getSettings: () => ({
workspaceDir: WORKSPACE_DIR,
agentCmdOverrides: { cursor: 'npx cursor-agent' }
it('reports no git provider for SSH model discovery and never discovers locally', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'cursor',
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
}
registerFilesystemHandlers(storeWithOverride as never)
await handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'cursor',
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
expect(discoverCommitMessageModelsRemoteMock).toHaveBeenCalledWith(
'cursor',
'/remote/repo',
expect.any(Function),
'npx cursor-agent'
)
const execute = discoverCommitMessageModelsRemoteMock.mock.calls[0]?.[2] as (
plan: unknown,
cwd: string,
timeoutMs: number
) => Promise<unknown>
await execute({ binary: 'cursor-agent', args: ['--list-models'] }, '/remote/repo', 60_000)
expect(executeCommitMessagePlan).toHaveBeenCalledWith(
{ binary: 'cursor-agent', args: ['--list-models'] },
'/remote/repo',
60_000
)
).resolves.toEqual({ success: false, error: 'No git provider for connection "conn-1"' })
expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled()
})
})
@@ -11,6 +11,7 @@ import {
openMock,
renameMock,
rmMock,
writeFileMock,
getSshFilesystemProviderMock,
promoteLocalDownloadedFolderMock,
resetFilesystemIpcMocks
@@ -113,142 +114,52 @@ describe('registerFilesystemHandlers', () => {
expect(showSaveDialogMock).not.toHaveBeenCalled()
})
it('rejects remote download directories before opening a dialog', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/src',
connectionId: 'ssh-1'
}
)
).rejects.toThrow('Cannot download a directory')
expect(showSaveDialogMock).not.toHaveBeenCalled()
expect(provider.downloadFile).not.toHaveBeenCalled()
})
it('returns canceled remote downloads without transferring', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: true })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
)
).resolves.toEqual({ canceled: true })
expect(showSaveDialogMock).toHaveBeenCalledWith({ defaultPath: 'report.pdf' })
expect(statMock).not.toHaveBeenCalled()
expect(provider.downloadFile).not.toHaveBeenCalled()
})
it('parents the remote download save dialog and sanitizes reserved filename suggestions', async () => {
it('parents the save dialog and sanitizes reserved filename suggestions', async () => {
const parentWindow = { id: 7 }
const sender = { id: 42 }
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
fromWebContentsMock.mockReturnValue(parentWindow)
showSaveDialogMock.mockResolvedValue({ canceled: true })
registerFilesystemHandlers(store as never)
await handlers.get('fs:downloadFile')!(
{ sender },
{
filePath: 'C:\\repo\\CON.txt',
connectionId: 'ssh-1'
}
)
await expect(
handlers.get('fs:saveDownloadedFile')!({ sender }, { suggestedName: 'CON.txt', content: '' })
).resolves.toEqual({ canceled: true })
expect(fromWebContentsMock).toHaveBeenCalledWith(sender)
expect(showSaveDialogMock).toHaveBeenCalledWith(parentWindow, { defaultPath: 'download' })
expect(writeFileMock).not.toHaveBeenCalled()
})
it('rejects remote downloads when raw provider transfer is unavailable', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 })
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/file.txt',
connectionId: 'ssh-1'
}
)
).rejects.toThrow('Remote file download is unavailable. Reconnect the SSH target and retry.')
expect(showSaveDialogMock).not.toHaveBeenCalled()
})
it('rejects selected local directories before transferring a remote download', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
it('rejects selected local directories before writing a download', async () => {
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockResolvedValue({ isDirectory: () => true })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
handlers.get('fs:saveDownloadedFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
{ suggestedName: 'report.pdf', content: 'hello' }
)
).rejects.toThrow('Cannot download to a directory')
expect(provider.downloadFile).not.toHaveBeenCalled()
expect(writeFileMock).not.toHaveBeenCalled()
})
it('downloads to a temp sibling then promotes a new destination', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockResolvedValue(undefined)
}
getSshFilesystemProviderMock.mockReturnValue(provider)
it('writes to a temp sibling then promotes a new destination', async () => {
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
writeFileMock.mockResolvedValue(undefined)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
handlers.get('fs:saveDownloadedFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
{ suggestedName: 'report.pdf', content: 'hello' }
)
).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.pdf' })
const tempPath = provider.downloadFile.mock.calls[0][1]
const tempPath = writeFileMock.mock.calls[0][0]
expect(path.dirname(tempPath)).toBe(path.normalize('/downloads'))
expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath)
expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf')
expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything())
})
@@ -316,79 +227,58 @@ describe('registerFilesystemHandlers', () => {
expect(renameMock).not.toHaveBeenCalled()
})
it('cleans up the temp sibling when remote download transfer fails', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed'))
}
getSshFilesystemProviderMock.mockReturnValue(provider)
it('cleans up the temp sibling when writing a download fails', async () => {
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
writeFileMock.mockRejectedValue(new Error('write failed'))
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
handlers.get('fs:saveDownloadedFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
{ suggestedName: 'report.pdf', content: 'hello' }
)
).rejects.toThrow('transfer failed')
).rejects.toThrow('write failed')
const tempPath = provider.downloadFile.mock.calls[0][1]
const tempPath = writeFileMock.mock.calls[0][0]
expect(renameMock).not.toHaveBeenCalled()
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
})
it('fails rather than overwriting a destination that appears after the dialog', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockResolvedValue(undefined)
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock
.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
.mockResolvedValueOnce({ isDirectory: () => false })
writeFileMock.mockResolvedValue(undefined)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
handlers.get('fs:saveDownloadedFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
{ suggestedName: 'report.pdf', content: 'hello' }
)
).rejects.toThrow('Destination file appeared before download completed')
const tempPath = provider.downloadFile.mock.calls[0][1]
const tempPath = writeFileMock.mock.calls[0][0]
expect(renameMock).not.toHaveBeenCalled()
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
})
it('uses a backup swap when overwriting an existing destination', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockResolvedValue(undefined)
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockResolvedValue({ isDirectory: () => false })
writeFileMock.mockResolvedValue(undefined)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
handlers.get('fs:saveDownloadedFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
{ suggestedName: 'report.pdf', content: 'hello' }
)
).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.pdf' })
const tempPath = provider.downloadFile.mock.calls[0][1]
const tempPath = writeFileMock.mock.calls[0][0]
const backupPath = renameMock.mock.calls[0][1]
expect(renameMock.mock.calls[0]).toEqual(['/downloads/report.pdf', backupPath])
expect(renameMock.mock.calls[1]).toEqual([tempPath, '/downloads/report.pdf'])
@@ -65,6 +65,7 @@ vi.mock(
import { registerFilesystemHandlers } from './filesystem'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
describe('registerFilesystemHandlers', () => {
beforeEach(() => {
@@ -224,31 +225,18 @@ describe('registerFilesystemHandlers pull request field generation', () => {
)
})
it('enriches the SSH pull-request context from host meta using the remote path', async () => {
const worktreeId = 'repo-1::/remote/repo'
getSshGitProviderMock.mockReturnValue({
exec: vi.fn(),
executeCommitMessagePlan: vi.fn()
})
const linkedStore = {
...store,
getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined)
}
it('refuses SSH pull-request generation as not served without preparing a local branch', async () => {
registerFilesystemHandlers(store as never)
registerFilesystemHandlers(linkedStore as never)
await handlers.get('git:generatePullRequestFields')!(null, {
...PULL_REQUEST_ARGS,
worktreePath: '/remote/repo',
worktreeId,
connectionId: 'conn-1'
})
expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith(
{ ...PULL_REQUEST_CONTEXT, linkedIssue: 77 },
params,
expect.objectContaining({ kind: 'remote' })
)
await expect(
handlers.get('git:generatePullRequestFields')!(null, {
...PULL_REQUEST_ARGS,
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: false, error: SSH_HOST_NOT_SERVED_MESSAGE })
expect(getPullRequestDraftContextMock).not.toHaveBeenCalled()
expect(loadPullRequestLinkedIssueMock).not.toHaveBeenCalled()
})
it('ignores a pull-request worktree id that does not own the requested path', async () => {
@@ -271,15 +259,10 @@ describe('registerFilesystemHandlers pull request field generation', () => {
)
})
it('forwards PR-context command limits through local and SSH adapters', async () => {
it('forwards PR-context command limits through the local adapter', async () => {
const localGitExec = vi
.spyOn(gitRunner, 'gitExecFileAsync')
.mockResolvedValue({ stdout: '', stderr: '' })
const sshExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
getSshGitProviderMock.mockReturnValue({
exec: sshExec,
executeCommitMessagePlan: vi.fn()
})
getPullRequestDraftContextMock.mockImplementation(async (execute) => {
await execute(['show-ref', '--verify'], { maxBuffer: 123, timeoutMs: 456 })
await execute(['legacy-probe'], { timeout: 789 })
@@ -303,19 +286,6 @@ describe('registerFilesystemHandlers pull request field generation', () => {
cwd: WORKTREE_FEATURE_PATH,
timeout: 789
})
await handlers.get('git:generatePullRequestFields')!(null, {
...PULL_REQUEST_ARGS,
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
expect(sshExec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', {
timeoutMs: 456
})
expect(sshExec).toHaveBeenCalledWith(['legacy-probe'], '/remote/repo', {
timeoutMs: 789
})
} finally {
localGitExec.mockRestore()
}
@@ -1,3 +1,4 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { handlers, store, resetFilesystemIpcMocks } from './filesystem-test-harness'
@@ -96,4 +97,53 @@ describe('fs:listFiles local name filter', () => {
).resolves.toEqual([])
expect(listQuickOpenFilesMock).not.toHaveBeenCalled()
})
it('returns no files for an SSH target and never lists locally', async () => {
registerHandlers()
await expect(
handlers.get('fs:listFiles')!(null, { rootPath: '/home/user/repo', connectionId: 'conn-1' })
).resolves.toEqual([])
expect(listQuickOpenFilesMock).not.toHaveBeenCalled()
})
// Why #7721: a workspace switch must stop the previous workspace's full-tree scan.
it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])(
'aborts an in-flight file listing on %s (#7721)',
async (eventName) => {
let capturedSignal: AbortSignal | undefined
listQuickOpenFilesMock.mockImplementation(
(_rootPath: string, _store: unknown, _exclude: unknown, signal?: AbortSignal) =>
new Promise<string[]>((_resolve, reject) => {
capturedSignal = signal
signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), {
once: true
})
})
)
registerHandlers()
// Why: cancellation keys are scoped to the issuing webContents.
const senderEvent = { sender: Object.assign(new EventEmitter(), { id: 7 }) }
const pending = Promise.resolve(
handlers.get('fs:listFiles')!(senderEvent, { rootPath: '/repo', requestToken: 'token-1' })
)
await Promise.resolve()
expect(capturedSignal?.aborted).toBe(false)
if (eventName === 'cancel') {
await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' })
} else {
senderEvent.sender.emit(eventName)
}
expect(capturedSignal?.aborted).toBe(true)
await expect(pending).rejects.toThrow('listing cancelled')
expect(senderEvent.sender.eventNames()).toEqual([])
// Unknown or already-settled tokens are a no-op, not an error.
expect(() =>
handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' })
).not.toThrow()
}
)
})
@@ -1,13 +1,13 @@
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MarkdownDocumentsModule from './markdown-documents'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
import {
handlers,
store,
WORKTREE_FEATURE_PATH,
readdirMock,
realpathMock,
getSshFilesystemProviderMock,
resetFilesystemIpcMocks
} from './filesystem-test-harness'
@@ -161,14 +161,7 @@ describe('registerFilesystemHandlers', () => {
).rejects.toThrow('Access denied')
})
it('lists remote markdown documents through the SSH filesystem provider', async () => {
const provider = {
listFiles: vi
.fn()
.mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts'])
}
getSshFilesystemProviderMock.mockReturnValue(provider)
it('refuses SSH markdown listing as not served and never lists locally', async () => {
registerFilesystemHandlers(store as never)
await expect(
@@ -176,58 +169,8 @@ describe('registerFilesystemHandlers', () => {
rootPath: '/home/user/project',
connectionId: 'ssh-1'
})
).resolves.toEqual([
{
filePath: '/home/user/project/docs/guide.mdx',
relativePath: 'docs/guide.mdx',
basename: 'guide.mdx',
name: 'guide'
},
{
filePath: '/home/user/project/README.md',
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
}
])
).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE)
expect(listMarkdownDocumentsMock).not.toHaveBeenCalled()
expect(localOptionsMock).not.toHaveBeenCalled()
})
it('keeps late Markdown documents from legacy providers with large source inventories', async () => {
const paths = Array.from({ length: 25_002 }, (_, index) => `src/file-${index}.ts`)
paths.push('docs/late.md')
const provider = { listFiles: vi.fn().mockResolvedValue(paths) }
getSshFilesystemProviderMock.mockReturnValue(provider)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the store reads used by filesystem handler registration.
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: '/repo',
connectionId: 'legacy'
})
).resolves.toEqual([
{
filePath: '/repo/docs/late.md',
relativePath: 'docs/late.md',
basename: 'late.md',
name: 'late'
}
])
expect(provider.listFiles).toHaveBeenCalledWith('/repo')
})
it('still bounds legacy source inventories before constructing Markdown metadata', async () => {
getSshFilesystemProviderMock.mockReturnValue({
listFiles: vi.fn().mockResolvedValue([`${'x'.repeat(65_537)}.ts`, 'README.md'])
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the store reads used by filesystem handler registration.
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: '/repo',
connectionId: 'legacy'
})
).rejects.toThrow('File inventory is too large')
})
})
@@ -9,7 +9,6 @@ import {
getPullRequestDraftContextMock,
resolveHostedReviewBodyForGenerationMock,
loadPullRequestLinkedIssueMock,
getSshGitProviderMock,
resetFilesystemIpcMocks
} from './filesystem-test-harness'
@@ -72,7 +71,7 @@ vi.mock('../source-control/pull-request-linked-issue', async () => {
import { registerFilesystemHandlers } from './filesystem'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => {
describe('PR linked-issue lifetime on a local worktree', () => {
const draftContext = {
base: 'main',
branch: 'feature/ai',
@@ -89,8 +88,7 @@ describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => {
title: '',
body: '',
draft: false,
worktreePath: host === 'SSH' ? '/remote/repo' : WORKTREE_FEATURE_PATH,
...(host === 'SSH' ? { connectionId: 'conn-1' } : {})
worktreePath: WORKTREE_FEATURE_PATH
}
beforeEach(() => {
@@ -103,10 +101,6 @@ describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => {
})
resolveHostedReviewBodyForGenerationMock.mockResolvedValue('')
getPullRequestDraftContextMock.mockResolvedValue(draftContext)
getSshGitProviderMock.mockReturnValue({
exec: vi.fn(),
executeCommitMessagePlan: vi.fn()
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing IPC harness provides every Store member reached by this handler.
registerFilesystemHandlers(store as never)
})
-2
View File
@@ -48,7 +48,6 @@ export const resolveCommitMessageSettingsMock: IpcMock = vi.fn()
export const generateCommitMessageFromContextMock: IpcMock = vi.fn()
export const generatePullRequestFieldsFromContextMock: IpcMock = vi.fn()
export const discoverCommitMessageModelsLocalMock: IpcMock = vi.fn()
export const discoverCommitMessageModelsRemoteMock: IpcMock = vi.fn()
export const cancelGenerateCommitMessageLocalMock: IpcMock = vi.fn()
export const cancelGeneratePullRequestFieldsLocalMock: IpcMock = vi.fn()
export const getPullRequestDraftContextMock: IpcMock = vi.fn()
@@ -141,7 +140,6 @@ export const textGenerationModuleMock = {
generateCommitMessageFromContext: generateCommitMessageFromContextMock,
generatePullRequestFieldsFromContext: generatePullRequestFieldsFromContextMock,
discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock,
cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock,
cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock
}
-204
View File
@@ -1,4 +1,3 @@
import { EventEmitter } from 'node:events'
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -15,7 +14,6 @@ import {
realpathMock,
lstatMock,
listWorktreesMock,
getSshFilesystemProviderMock,
tryDeleteWslUncPathMock,
recordCrashBreadcrumbMock,
resetFilesystemIpcMocks,
@@ -92,30 +90,6 @@ describe('registerFilesystemHandlers', () => {
expect(handlers.has('fs:authorizeExternalPath')).toBe(false)
})
it('re-sorts SSH provider listings directories-first in natural order', async () => {
// Why: the remote relay may be an older build that still sorts lexicographically.
getSshFilesystemProviderMock.mockReturnValueOnce({
readDir: vi.fn().mockResolvedValue([
{ name: '100 - b.txt', isDirectory: false, isSymlink: false },
{ name: '9 - c.txt', isDirectory: false, isSymlink: false },
{ name: '10 - dir', isDirectory: true, isSymlink: false },
{ name: '99 - a.txt', isDirectory: false, isSymlink: false }
])
})
registerFilesystemHandlers(store as never)
const result = (await handlers.get('fs:readDir')!(null, {
dirPath: '/remote/repo',
connectionId: 'ssh-1'
})) as { name: string }[]
expect(result.map((e) => e.name)).toEqual([
'10 - dir',
'9 - c.txt',
'99 - a.txt',
'100 - b.txt'
])
})
it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => {
registerFilesystemHandlers(store as never)
@@ -156,7 +130,6 @@ describe('registerFilesystemHandlers', () => {
it('records a breadcrumb tagged ssh-provider when the SSH provider is gone', async () => {
registerFilesystemHandlers(store as never)
getSshFilesystemProviderMock.mockReturnValue(undefined)
await expect(
handlers.get('fs:readDir')!(null, { dirPath: '/remote/repo', connectionId: 'ssh-1' })
@@ -284,24 +257,6 @@ describe('registerFilesystemHandlers', () => {
expect(statMock).toHaveBeenCalledWith(targetPath)
})
it('returns false from pathExists when an SSH provider reports a missing path', async () => {
const provider = {
stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:pathExists')!(null, {
filePath: '/remote/repo/untitled-7.md',
connectionId: 'ssh-1'
})
).resolves.toBe(false)
expect(provider.stat).toHaveBeenCalledWith('/remote/repo/untitled-7.md')
})
it('allows deletePath when a registered worktree parent resolves to a macOS canonical alias', async () => {
const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature')
const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature')
@@ -544,163 +499,4 @@ describe('registerFilesystemHandlers', () => {
isBinary: true
})
})
// Why: the original SSH Quick Open bug had two halves — relay-side policy
// drift AND the main dispatcher silently dropping excludePaths before the
// provider saw them. This test guards the second half: regardless of
// relay behavior, a new linked worktree under the root must be forwarded
// so the remote scan can prune it. See docs/design/share-quick-open-file-listing.md.
it('fs:listFiles forwards excludePaths to the SSH filesystem provider', async () => {
const listFilesMock = vi.fn().mockResolvedValue([])
getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock })
registerFilesystemHandlers(store as never)
await handlers.get('fs:listFiles')!(null, {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
excludePaths: ['/home/user/repo/worktrees/feature']
})
expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', {
excludePaths: ['/home/user/repo/worktrees/feature']
})
})
it.each([1, 2, 3, 4])(
'keeps plain SSH queries on version %s hosts beyond the prefix limit',
async (version) => {
const listFilesMock = vi.fn().mockResolvedValue(['src/target.ts'])
const supportsQuickOpenSearch = vi.fn(
async (options: { minimumVersion?: number }) => version >= (options.minimumVersion ?? 3)
)
const inventory = [...Array.from({ length: 33 }, (_, i) => `file${i}.ts`), 'src/target.ts']
listFilesMock.mockImplementation(async (_root, options) =>
options.searchQuery
? inventory.filter((file) => file.includes(options.searchQuery))
: inventory.slice(0, options.maxResults)
)
getSshFilesystemProviderMock.mockReturnValue({
listFiles: listFilesMock,
supportsQuickOpenSearch
})
registerFilesystemHandlers(store as never)
const request = {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
maxResults: 33,
searchQuery: 'target'
}
await expect(handlers.get('fs:listFiles')!(null, request)).resolves.toEqual(['src/target.ts'])
expect(listFilesMock).toHaveBeenCalledOnce()
expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ signal: undefined, minimumVersion: 1 })
expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', {
excludePaths: undefined,
maxResults: 33,
searchQuery: 'target'
})
for (const options of [
{ includeIgnored: false },
{ followSymlinks: true },
{ candidatePaths: ['src/target.ts'] },
{ searchQuery: 'tar get' },
{ searchQuery: 'tar-get' },
{ searchQuery: 'tar_get' }
]) {
listFilesMock.mockClear()
const operation = handlers.get('fs:listFiles')!(null, { ...request, ...options })
const minimum = 'candidatePaths' in options ? 3 : 'searchQuery' in options ? 1 : 2
if (version < minimum) {
await expect(operation).rejects.toThrow('Update the remote host')
expect(listFilesMock).not.toHaveBeenCalled()
} else {
await operation
expect(listFilesMock).toHaveBeenCalledOnce()
expect(listFilesMock.mock.calls[0][1]).toMatchObject({ ...options, maxResults: 33 })
}
}
}
)
it.each(['target', 'tar get'])(
'retains bounded legacy SSH behavior for %s without capabilities',
async (query) => {
const listFilesMock = vi.fn().mockResolvedValue(['src/target.ts', 'src/index.ts'])
const supportsQuickOpenSearchMock = vi.fn().mockResolvedValue(false)
getSshFilesystemProviderMock.mockReturnValue({
listFiles: listFilesMock,
supportsQuickOpenSearch: supportsQuickOpenSearchMock
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listFiles')!(null, {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
maxResults: 2,
searchQuery: query
})
).resolves.toEqual(['src/target.ts'])
expect(supportsQuickOpenSearchMock).toHaveBeenCalled()
expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', {
excludePaths: undefined,
maxResults: 33
})
}
)
// Why #7721: without a cancel path, every workspace switch left the previous
// workspace's full-tree SSH scan running, stacking scans on the relay until
// interactive fs.readDir/fs.stat starved past their 30s timeout.
it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])(
'aborts an in-flight SSH file listing on %s (#7721)',
async (eventName) => {
let capturedSignal: AbortSignal | undefined
const listFilesMock = vi.fn(
(_rootPath: string, options: { signal?: AbortSignal }) =>
new Promise<string[]>((_resolve, reject) => {
capturedSignal = options.signal
options.signal?.addEventListener(
'abort',
() => reject(new Error('listing cancelled')),
{
once: true
}
)
})
)
getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock })
registerFilesystemHandlers(store as never)
// Why: cancellation keys are scoped to the issuing webContents, so the
// cancel must come from the same sender as the listing request.
const senderEvent = { sender: Object.assign(new EventEmitter(), { id: 7 }) }
const pending = handlers.get('fs:listFiles')!(senderEvent, {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
requestToken: 'token-1'
}) as Promise<string[]>
await Promise.resolve()
expect(capturedSignal?.aborted).toBe(false)
if (eventName === 'cancel') {
await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' })
} else {
senderEvent.sender.emit(eventName)
}
expect(capturedSignal?.aborted).toBe(true)
await expect(pending).rejects.toThrow('listing cancelled')
expect(senderEvent.sender.eventNames()).toEqual([])
// Unknown or already-settled tokens are a no-op, not an error.
expect(() =>
handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' })
).not.toThrow()
}
)
})
@@ -1,8 +1,7 @@
import { ipcMain } from 'electron'
import { readLocalFileRange } from './local-file-range-read'
import { validateFileRangeRequest } from '../../../shared/file-range-read'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { readSshFileExplorerChunk } from '../../runtime/ssh-file-explorer-chunk-read'
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
import { resolveLocalFileRequestPath } from '../local-file-access-resolution'
import type { LocalFileAccess } from '../../../shared/local-file-access'
import type { FilesystemHandlerContext } from './filesystem-handler-context'
@@ -22,18 +21,7 @@ export function registerFilesystemChunkReadHandler({ store }: FilesystemHandlerC
) => {
validateFileRangeRequest(args.offset, args.length)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
const stats = await provider.stat(args.filePath)
if (stats.type === 'directory') {
throw new Error('Cannot read a directory')
}
return readSshFileExplorerChunk(
provider,
args.filePath,
stats.size,
args.offset,
args.length
)
throw new SshHostNotServedError(args.connectionId)
}
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
return readLocalFileRange(filePath, args.offset, args.length)
@@ -2,7 +2,7 @@ import { ipcMain } from 'electron'
import type { SearchOptions, SearchResult } from '../../../shared/code-search-types'
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../../shared/abort-signal-reason'
import { parseWslPath } from '../../wsl'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
import { resolveDesktopAuthorizedPath } from '../local-file-access-resolution'
import { createSenderScopedRequestCancellations } from '../sender-scoped-request-cancellation'
import { stopBundledRipgrep } from '../../ripgrep/bundled-ripgrep-stop'
@@ -29,8 +29,7 @@ export function registerFilesystemContentSearchHandler(context: FilesystemHandle
try {
throwIfSignalAborted(signal)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return await provider.search(options, { signal })
throw new SshHostNotServedError(args.connectionId)
}
const rootPath = await waitForPromiseWithSignal(
resolveDesktopAuthorizedPath(args.rootPath, store),
@@ -1,8 +1,7 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { randomUUID } from 'node:crypto'
import { open, writeFile } from 'node:fs/promises'
import { getRuntimePathBasename } from '../../../shared/cross-platform-path'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
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'
@@ -29,45 +28,11 @@ export function registerFilesystemDownloadHandlers(context: FilesystemHandlerCon
ipcMain.handle(
'fs:downloadFile',
async (
event,
args: { filePath?: string; connectionId?: string }
): Promise<DownloadFileResult> => {
const filePath = validateRequiredString(args?.filePath, 'filePath')
async (_event, args: { filePath?: string; connectionId?: string }): Promise<never> => {
validateRequiredString(args?.filePath, 'filePath')
const connectionId = validateRequiredString(args?.connectionId, 'connectionId')
const provider = requireSshFilesystemProvider(connectionId)
const remoteStat = await provider.stat(filePath)
if (remoteStat.type === 'directory') {
throw new Error('Cannot download a directory')
}
if (!provider.downloadFile) {
throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.')
}
const remoteBasename = getRuntimePathBasename(filePath)
const defaultPath = sanitizeLocalDownloadFilename(remoteBasename)
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
const dialogResult = parentWindow
? await dialog.showSaveDialog(parentWindow, { defaultPath })
: await dialog.showSaveDialog({ defaultPath })
if (dialogResult.canceled || !dialogResult.filePath) {
return { canceled: true }
}
const destinationPath = dialogResult.filePath
const { existed } = await inspectDownloadDestination(destinationPath)
const tempPath = createSiblingTransferPath(destinationPath, 'download')
let promoted = false
try {
await provider.downloadFile(filePath, tempPath)
await promoteDownloadedFile(tempPath, destinationPath, existed)
promoted = true
return { canceled: false, destinationPath }
} finally {
if (!promoted) {
await cleanupLocalTransferPath(tempPath)
}
}
// Why: only SSH targets downloaded through here, and this process serves none.
throw new SshHostNotServedError(connectionId)
}
)
@@ -9,10 +9,7 @@ import {
} from '../../text-generation/commit-message-text-generation'
import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key'
import { getStagedCommitContext } from '../../git/status'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../../providers/ssh-git-dispatch'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../../providers/ssh-host-not-served-error'
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options'
import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables'
@@ -65,37 +62,7 @@ export function registerFilesystemGitCommitGenerationHandlers(
return { success: false, error: resolvedSettings.error }
}
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
return {
success: false,
error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
}
}
let context
try {
context = await provider.getStagedCommitContext(args.worktreePath)
} catch (error) {
console.error('[filesystem] Failed to read remote staged commit context:', error)
return {
success: false,
error: 'Failed to read staged changes.'
}
}
if (!context) {
return { success: false, error: 'No staged changes to summarize.' }
}
context = withLinkedIssueDraftContext(
context,
resolveSourceControlAiLinkedIssue(store, args)
)
return generateCommitMessageFromContext(context, resolvedSettings.params, {
kind: 'remote',
cwd: args.worktreePath,
execute: (plan, cwd, timeoutMs, operation) =>
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
missingBinaryLocation: 'remote PATH'
})
return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -143,11 +110,6 @@ export function registerFilesystemGitCommitGenerationHandlers(
'git:cancelGenerateCommitMessage',
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
return
}
await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message')
return
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
@@ -3,12 +3,10 @@ import type { TuiAgent } from '../../../shared/tui-agent'
import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment'
import {
discoverCommitMessageModelsLocal,
discoverCommitMessageModelsRemote,
type DiscoverCommitMessageModelsResult
} from '../../text-generation/commit-message-text-generation'
import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment'
import { parseWslPath } from '../../wsl'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import {
resolveModelDiscoveryLocalPath,
getLocalAgentRuntimeTarget
@@ -32,19 +30,10 @@ export function registerFilesystemGitModelDiscoveryHandlers(
if (!args.worktreePath) {
return { success: false, error: 'Missing worktree path for remote model discovery.' }
}
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
return {
success: false,
error: `No git provider for connection "${args.connectionId}"`
}
return {
success: false,
error: `No git provider for connection "${args.connectionId}"`
}
return discoverCommitMessageModelsRemote(
agentId as TuiAgent,
args.worktreePath,
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
agentCommandOverride
)
}
let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' }
let localDiscoveryOptions: Parameters<typeof discoverCommitMessageModelsLocal>[3]
@@ -11,14 +11,10 @@ import {
import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key'
import { getPullRequestDraftContext } from '../../text-generation/pull-request-context'
import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../../providers/ssh-git-dispatch'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../../providers/ssh-host-not-served-error'
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options'
import { gitExecFileAsync } from '../../git/runner'
import { execSshReviewDraft } from '../../providers/ssh-review-draft-context'
import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables'
import { resolveSourceControlAiLinkedIssueMeta } from '../source-control-ai-linked-issue'
import { resolveHostedReviewBodyForGeneration } from '../../source-control/pull-request-template'
@@ -76,64 +72,7 @@ export function registerFilesystemGitPullRequestGenerationHandlers(
return { success: false, error: resolvedSettings.error }
}
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
return {
success: false,
error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
}
}
const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args)
const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({
meta: issueMeta,
provider: args.provider,
repoPath: args.worktreePath,
connectionId: args.connectionId
})
// Preparation can return before this lookup settles; retain its error for the later await.
void linkedIssueDetailsPromise.catch(() => undefined)
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: args.body,
repoPath: args.worktreePath,
connectionId: args.connectionId,
provider: args.provider,
useTemplate: args.useTemplate
})
context = await getPullRequestDraftContext(
(argv, commandOptions) =>
execSshReviewDraft(provider, argv, args.worktreePath, commandOptions),
{
base: args.base,
currentTitle: args.title,
currentBody,
currentDraft: args.draft
}
)
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Failed to prepare branch for PR details.'
}
}
if (!context) {
return { success: false, error: 'No branch changes to summarize.' }
}
const linkedIssueDetails = await linkedIssueDetailsPromise
context = {
...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue),
...(args.provider ? { provider: args.provider } : {}),
...(linkedIssueDetails ? { linkedIssueDetails } : {})
}
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
kind: 'remote',
cwd: args.worktreePath,
execute: (plan, cwd, timeoutMs, operation) =>
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
missingBinaryLocation: 'remote PATH'
})
return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
@@ -147,7 +86,6 @@ export function registerFilesystemGitPullRequestGenerationHandlers(
meta: issueMeta,
provider: args.provider,
repoPath: worktreePath,
connectionId: args.connectionId,
localGitOptions: gitOptions
})
// Preparation can return before this lookup settles; retain its error for the later await.
@@ -157,7 +95,6 @@ export function registerFilesystemGitPullRequestGenerationHandlers(
const currentBody = await resolveHostedReviewBodyForGeneration({
body: args.body,
repoPath: worktreePath,
connectionId: args.connectionId,
provider: args.provider,
useTemplate: args.useTemplate
})
@@ -213,11 +150,6 @@ export function registerFilesystemGitPullRequestGenerationHandlers(
'git:cancelGeneratePullRequestFields',
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
return
}
await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields')
return
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
@@ -1,4 +1,3 @@
import { listFilesystemMarkdownDocuments } from '../../providers/filesystem-markdown-listing'
import { classifyFilesystemDirectoryEntries } from '../filesystem-symlink-directory-entries'
import { markdownDocumentsFromRelativePaths } from '../../../shared/markdown-document-paths'
import {
@@ -10,7 +9,7 @@ import { ipcMain } from 'electron'
import { readdir, stat } from 'node:fs/promises'
import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types'
import { sortDirEntries } from '../../../shared/file-name-sort'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
import type { LocalFileAccess } from '../../../shared/local-file-access'
import {
@@ -46,11 +45,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
try {
if (args.connectionId) {
throwSite = 'ssh-provider'
const provider = requireSshFilesystemProvider(args.connectionId)
// Why: re-sort locally — the remote relay may be an older build with lexicographic ordering.
return sortDirEntries(
await provider.readDir(args.dirPath, { followSymlinks: args.followSymlinks })
)
throw new SshHostNotServedError(args.connectionId)
}
const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store)
throwSite = 'readdir'
@@ -89,12 +84,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
}
): Promise<LocalFileContent> => {
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
const media = readMediaPreview(event, args, store)
if (media) {
return media
}
return provider.readFile(args.filePath)
throw new SshHostNotServedError(args.connectionId)
}
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
const media = readMediaPreview(event, args, store)
@@ -114,8 +104,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
args: { rootPath: string; connectionId?: string }
): Promise<MarkdownDocument[]> => {
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return listFilesystemMarkdownDocuments(provider, args.rootPath)
throw new SshHostNotServedError(args.connectionId)
}
const isFolderRoot = store
.getFolderWorkspaces?.()
@@ -143,9 +132,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
args: { filePath: string; connectionId?: string; access?: LocalFileAccess }
): Promise<{ size: number; isDirectory: boolean; mtime: number }> => {
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
const result = await provider.stat(args.filePath)
return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime }
throw new SshHostNotServedError(args.connectionId)
}
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
const stats = await stat(filePath)
@@ -160,17 +147,14 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
args: { filePaths: string[]; connectionId?: string }
): Promise<PathExistenceResult[]> => {
validatePathExistenceBatch(args.filePaths)
const provider = args.connectionId ? requireSshFilesystemProvider(args.connectionId) : null
if (provider?.pathsExist) {
return provider.pathsExist(args.filePaths)
if (args.connectionId) {
throw new SshHostNotServedError(args.connectionId)
}
return Promise.all(
args.filePaths.map((filePath) =>
capturePathExistence(async () => {
try {
await (provider
? provider.stat(filePath)
: stat(await resolveDesktopAuthorizedPath(filePath, store)))
await stat(await resolveDesktopAuthorizedPath(filePath, store))
return true
} catch (error) {
if (isENOENT(error)) {
@@ -192,9 +176,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
): Promise<boolean> => {
try {
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
await provider.stat(args.filePath)
return true
throw new SshHostNotServedError(args.connectionId)
}
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
await stat(filePath)
@@ -1,19 +1,13 @@
import { resolveSshQuickOpenDiscoveryOptions } from '../../providers/ssh-quick-open-discovery-options'
import { ipcMain } from 'electron'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { listQuickOpenFiles } from '../filesystem-list-files'
import {
isFileNameFilterQueryTooLarge,
pathMatchesFileNameFilterTokens,
splitFileNameFilterTokens
} from '../../../shared/file-name-filter-tokens'
import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search'
import type { FilesystemHandlerContext } from './filesystem-handler-context'
import { registerFilesystemContentSearchHandler } from './filesystem-content-search-handler'
// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling.
const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33
export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void {
registerFilesystemContentSearchHandler(context)
const { store } = context
@@ -39,58 +33,9 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
): Promise<string[]> => {
const controller = listFilesCancellations.begin(event, args.requestToken)
try {
// Why: an SSH target is not served here; quick-open shows "No matching files", as it did when disconnected.
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
// Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error.
if (!provider) {
return []
}
const discovery = await resolveSshQuickOpenDiscoveryOptions(
provider,
args,
controller?.signal
)
if (
args.candidatePaths !== undefined &&
!(await provider.supportsQuickOpenSearch?.({
signal: controller?.signal,
minimumVersion: 3
}))
) {
throw new Error('Update the remote host to validate Quick Open recent files.')
}
// Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results.
if (
args.searchQuery !== undefined &&
provider.supportsQuickOpenSearch &&
!(await provider.supportsQuickOpenSearch({
signal: controller?.signal,
minimumVersion: 1
}))
) {
const legacyFiles = await provider.listFiles(args.rootPath, {
excludePaths: args.excludePaths,
...discovery,
maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT,
signal: controller?.signal
})
const ranker = new QuickOpenPathRanker(
args.searchQuery,
args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT
)
for (const file of legacyFiles) {
ranker.consider(file)
}
return ranker.result().paths
}
return await provider.listFiles(args.rootPath, {
candidatePaths: args.candidatePaths,
excludePaths: args.excludePaths,
...discovery,
...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }),
...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }),
signal: controller?.signal
})
return []
}
if (args.nameFilter !== undefined && isFileNameFilterQueryTooLarge(args.nameFilter)) {
return []
@@ -7,7 +7,6 @@ import {
import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment'
import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation'
import { resolve } from 'node:path'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { listRepoWorktreeGraph } from '../../repo-worktrees'
import { resolveAuthorizedPath } from '../filesystem-auth'
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
@@ -91,28 +90,11 @@ async function localRepoOwnsWorktree(
}
}
async function remoteRepoOwnsWorktree(
store: Store,
repo: Repo,
worktreePath: string,
connectionId: string
): Promise<boolean> {
const comparableWorktreePath = comparableRemotePath(worktreePath)
if (comparableRemotePath(repo.path) === comparableWorktreePath) {
return true
}
const provider = getSshGitProvider(connectionId)
if (!provider) {
return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath)
}
try {
const worktrees = await provider.listWorktrees(repo.path)
return worktrees.some(
(worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath
)
} catch {
return false
}
function remoteRepoOwnsWorktree(store: Store, repo: Repo, worktreePath: string): boolean {
return (
comparableRemotePath(repo.path) === comparableRemotePath(worktreePath) ||
hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath)
)
}
export async function getRepoForSourceControlAi(
@@ -131,9 +113,7 @@ export async function getRepoForSourceControlAi(
return null
}
// Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo.
return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId))
? repo
: null
return remoteRepoOwnsWorktree(store, repo, args.worktreePath) ? repo : null
}
if (repo.connectionId) {
return null
@@ -2,7 +2,7 @@ import { ipcMain, shell } from 'electron'
import { lstat, writeFile } from 'node:fs/promises'
import type { SshMutationExpectation } from '../../../shared/ssh-types'
import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
import { tryDeleteWslUncPath } from '../../wsl-unc-delete'
import type { LocalFileAccess } from '../../../shared/local-file-access'
import {
@@ -36,10 +36,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
args.expectedExecutionHostId
)
if (args.connectionId) {
return requireSshFilesystemProvider(args.connectionId).writeFile(
args.filePath,
args.content
)
throw new SshHostNotServedError(args.connectionId)
}
const filePath = await resolveLocalWriteRequestPath(args.filePath, args.access, store)
try {
@@ -74,10 +71,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
args.expectedExecutionHostId
)
if (args.connectionId) {
return requireSshFilesystemProvider(args.connectionId).deletePath(
args.targetPath,
args.recursive
)
throw new SshHostNotServedError(args.connectionId)
}
// Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots).
const targetPath = await resolveDesktopAuthorizedPath(args.targetPath, store, {
@@ -25,8 +25,6 @@ vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleM
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -25,8 +25,6 @@ vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleM
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -347,6 +345,19 @@ describe('repos:add + repos:clone', () => {
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
})
it('refuses project setup on an SSH target with the not-connected text and adds no repo', async () => {
await expect(
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: 'github:acme/orca',
hostId: 'ssh:conn-1',
path: '/srv/orca',
kind: 'git'
})
).rejects.toThrow('SSH connection "conn-1" not found or not connected')
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
const updated = {
id: 'repo-update-root',
@@ -29,8 +29,6 @@ vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleM
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
+99 -367
View File
@@ -28,8 +28,6 @@ vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleM
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -37,14 +35,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
import { getGitRepoRoot, isGitRepo } from '../git/repo'
import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness'
const {
handleMock,
mockStore,
mockGitProvider,
mockFilesystemProvider,
mockMultiplexer,
listWorktreeGraphMock
} = reposMocks
const { handleMock, mockStore, mockFilesystemProvider, listWorktreeGraphMock } = reposMocks
beforeEach(() => {
clearGitCapabilityStateForTests()
@@ -62,357 +53,122 @@ describe('projectGroups IPC validation', () => {
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
// Why: a local tree whose root is a plain folder and whose listed dirs are git repos.
async function withNestedTree(
repoDirs: string[],
run: (root: string) => Promise<void>
): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'orca-nested-import-'))
try {
for (const dir of repoDirs) {
await mkdir(join(root, dir, '.git'), { recursive: true })
}
vi.mocked(isGitRepo).mockImplementation((path: string) => path !== root)
await run(root)
} finally {
await rm(root, { recursive: true, force: true })
}
}
it('uses completed scan ids as an allowlist for nested imports', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api' || path === '/srv/platform/node_modules/hidden',
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
if (path === '/srv/platform/node_modules/hidden/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => {
if (dirPath === '/srv/platform') {
return [
{ name: 'api', isDirectory: true, isSymlink: false },
{ name: 'node_modules', isDirectory: true, isSymlink: false }
]
}
return []
})
await withNestedTree(['api', join('node_modules', 'hidden')], async (root) => {
await handlers.get('projectGroups:scanNested')!(
{ sender: { send: vi.fn() } },
{ path: root, scanId: 'scan-import-allowlist' }
)
await handlers.get('projectGroups:scanNested')!(
{ sender: { send: vi.fn() } },
{
path: '/srv/platform',
connectionId: 'conn-1',
scanId: 'scan-import-allowlist'
}
)
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: root,
groupName: 'Platform',
projectPaths: [join(root, 'api'), join(root, 'node_modules', 'hidden')],
scanId: 'scan-import-allowlist',
mode: 'group'
})
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform',
groupName: 'Platform',
projectPaths: ['/srv/platform/api', '/srv/platform/node_modules/hidden'],
connectionId: 'conn-1',
scanId: 'scan-import-allowlist',
mode: 'group'
expect(result).toMatchObject({ importedCount: 1, failedCount: 1 })
expect(mockStore.addRepo).toHaveBeenCalledTimes(1)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path: join(root, 'api') })
)
})
expect(result).toMatchObject({ importedCount: 1, failedCount: 1 })
expect(mockStore.addRepo).toHaveBeenCalledTimes(1)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path: '/srv/platform/api' })
)
})
it('does not reuse a completed nested scan id for a different SSH parent path', async () => {
const group = {
id: 'group-1',
name: 'Other',
parentPath: '/srv/other',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
it('does not reuse a completed nested scan id for a different parent path', async () => {
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api' || path === '/srv/other/api',
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git' || path === '/srv/other/api/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => {
if (dirPath === '/srv/platform' || dirPath === '/srv/other') {
return [{ name: 'api', isDirectory: true, isSymlink: false }]
}
return []
})
await withNestedTree([join('platform', 'api'), join('other', 'api')], async (root) => {
await handlers.get('projectGroups:scanNested')!(
{ sender: { send: vi.fn() } },
{ path: join(root, 'platform'), scanId: 'scan-parent-context' }
)
mockStore.addRepo.mockClear()
await handlers.get('projectGroups:scanNested')!(
{ sender: { send: vi.fn() } },
{
path: '/srv/platform',
connectionId: 'conn-1',
scanId: 'scan-parent-context'
}
)
mockStore.addRepo.mockClear()
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: join(root, 'other'),
groupName: 'Other',
projectPaths: [join(root, 'platform', 'api')],
scanId: 'scan-parent-context',
mode: 'group'
})
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/other',
groupName: 'Other',
projectPaths: ['/srv/platform/api'],
connectionId: 'conn-1',
scanId: 'scan-parent-context',
mode: 'group'
expect(result).toMatchObject({ importedCount: 0, failedCount: 1 })
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
expect(result).toMatchObject({ importedCount: 0, failedCount: 1 })
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(mockFilesystemProvider.readDir).toHaveBeenCalledWith('/srv/other')
})
it('does not reuse a completed nested scan id for a different SSH connection', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api',
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
)
await handlers.get('projectGroups:scanNested')!(
{ sender: { send: vi.fn() } },
{
path: '/srv/platform',
connectionId: 'conn-1',
scanId: 'scan-connection-context'
}
)
mockStore.addRepo.mockClear()
it('refuses nested imports on an SSH target and never inspects the path locally', async () => {
await expect(
handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform',
groupName: 'Platform',
projectPaths: ['/srv/platform/api'],
connectionId: 'missing-conn',
scanId: 'scan-connection-context',
connectionId: 'conn-1',
mode: 'group'
})
).rejects.toThrow('ssh_connection_unavailable')
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(isGitRepo).not.toHaveBeenCalled()
expect(mockFilesystemProvider.readDir).not.toHaveBeenCalled()
})
it('imports nested SSH repositories with connection-scoped repo entries', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
it('imports a small selection from a large nested scan', async () => {
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api',
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
)
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform',
groupName: 'Platform',
projectPaths: ['/srv/platform/api'],
connectionId: 'conn-1',
mode: 'group'
})
expect(result).toMatchObject({ importedCount: 1, failedCount: 0 })
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/srv/platform/api',
connectionId: 'conn-1',
projectGroupId: group.id
})
)
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
rootPath: '/srv/platform/api'
})
})
it('resolves SSH linked worktree imports through the SSH provider worktree graph', async () => {
const selectedPath = '/srv/platform/demo/brash-binder'
const secondSelectedPath = '/srv/platform/demo/quick-howler'
const mainPath = '/srv/source/demo-project'
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === selectedPath || path === secondSelectedPath,
rootPath: '/srv/provider/root'
}))
mockGitProvider.listWorktrees.mockResolvedValue([
{
path: mainPath,
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: selectedPath,
head: 'feature-head',
branch: 'refs/heads/brash-binder',
isBare: false,
isMainWorktree: false
},
{
path: secondSelectedPath,
head: 'feature-head',
branch: 'refs/heads/quick-howler',
isBare: false,
isMainWorktree: false
}
])
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === `${selectedPath}/.git` || path === `${secondSelectedPath}/.git`) {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform/demo'
? [
{ name: 'brash-binder', isDirectory: true, isSymlink: false },
{ name: 'quick-howler', isDirectory: true, isSymlink: false }
]
: []
)
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform/demo',
groupName: '',
projectPaths: [selectedPath, secondSelectedPath],
connectionId: 'conn-1',
mode: 'separate'
})
expect(result).toMatchObject({ importedCount: 1, alreadyKnownCount: 1, failedCount: 0 })
expect(mockGitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath)
expect(mockGitProvider.listWorktrees).toHaveBeenCalledTimes(1)
expect(listWorktreeGraphMock).not.toHaveBeenCalled()
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: mainPath,
connectionId: 'conn-1'
})
)
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
rootPath: mainPath
})
})
it('imports a small selection from a large nested SSH scan', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const repoPaths = Array.from(
const names = Array.from(
{ length: 87 },
(_, index) => `/srv/platform/service-${String(index + 1).padStart(2, '0')}`
(_, index) => `service-${String(index + 1).padStart(2, '0')}`
)
const selectedPaths = [repoPaths[2], repoPaths[41], repoPaths[86]]
mockStore.addRepo.mockClear()
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: repoPaths.includes(path),
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
const repoPath = path.replace(/\/\.git$/, '')
if (path.endsWith('/.git') && repoPaths.includes(repoPath)) {
return { type: 'directory', size: 0, mtime: 0 }
await withNestedTree(names, async (root) => {
const selectedPaths = [names[2], names[41], names[86]].map((name) => join(root, name))
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: root,
groupName: 'Platform',
projectPaths: selectedPaths,
mode: 'group'
})
expect(result).toMatchObject({ importedCount: 3, alreadyKnownCount: 0, failedCount: 0 })
expect(mockStore.addRepo).toHaveBeenCalledTimes(3)
for (const path of selectedPaths) {
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path, projectGroupId: group.id })
)
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform'
? repoPaths.map((repoPath) => ({
name: repoPath.split('/').at(-1) ?? repoPath,
isDirectory: true,
isSymlink: false
}))
: []
)
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform',
groupName: 'Platform',
projectPaths: selectedPaths,
connectionId: 'conn-1',
mode: 'group'
})
expect(result).toMatchObject({
importedCount: 3,
alreadyKnownCount: 0,
failedCount: 0
})
expect(mockStore.addRepo).toHaveBeenCalledTimes(3)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path: selectedPaths[0], projectGroupId: group.id })
)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path: selectedPaths[1], projectGroupId: group.id })
)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path: selectedPaths[2], projectGroupId: group.id })
)
})
it('imports selected local linked worktrees as one project rooted at the main worktree', async () => {
@@ -477,44 +233,20 @@ describe('projectGroups IPC validation', () => {
})
it('sanitizes unexpected nested import errors before returning results', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api',
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
)
mockStore.addRepo.mockImplementationOnce(() => {
throw new Error('secret backend path /srv/platform/api')
})
await withNestedTree(['api'], async (root) => {
mockStore.addRepo.mockImplementationOnce(() => {
throw new Error(`secret backend path ${join(root, 'api')}`)
})
const result = (await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform',
groupName: 'Platform',
projectPaths: ['/srv/platform/api'],
connectionId: 'conn-1',
mode: 'group'
})) as { projects: { error?: string }[] }
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: root,
groupName: 'Platform',
projectPaths: [join(root, 'api')],
mode: 'group'
})
expect(result.projects[0].error).toBe('Repository could not be imported')
expect(result).toMatchObject({ projects: [{ error: 'Repository could not be imported' }] })
})
})
})
+10 -182
View File
@@ -28,8 +28,6 @@ vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleM
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -37,7 +35,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
import { getGitRepoRoot, isGitRepo } from '../git/repo'
import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness'
const { handleMock, mockStore, mockGitProvider, mockFilesystemProvider } = reposMocks
const { handleMock, mockStore, mockFilesystemProvider } = reposMocks
beforeEach(() => {
clearGitCapabilityStateForTests()
@@ -55,185 +53,15 @@ describe('projectGroups IPC validation', () => {
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('scans nested repositories over a connected SSH filesystem', async () => {
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api',
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
)
const result = await handlers.get('projectGroups:scanNested')!(null, {
path: '/srv/platform',
connectionId: 'conn-1'
})
expect(result).toMatchObject({
selectedPath: '/srv/platform',
selectedPathKind: 'non_git_folder',
repos: [{ path: '/srv/platform/api', displayName: 'api' }]
})
})
it('detects nested bare repositories over a connected SSH filesystem', async () => {
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: false, rootPath: null })
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/mirror.git/HEAD') {
return { type: 'file', size: 0, mtime: 0 }
}
if (path === '/srv/platform/mirror.git/objects' || path === '/srv/platform/mirror.git/refs') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform'
? [{ name: 'mirror.git', isDirectory: true, isSymlink: false }]
: []
)
const result = await handlers.get('projectGroups:scanNested')!(null, {
path: '/srv/platform',
connectionId: 'conn-1'
})
expect(result).toMatchObject({
selectedPath: '/srv/platform',
selectedPathKind: 'non_git_folder',
repos: [{ path: '/srv/platform/mirror.git', displayName: 'mirror.git' }]
})
})
it('skips symlinked directories during SSH nested repository scans', async () => {
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: false, rootPath: null })
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git' || path === '/srv/platform/linked-outside/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform'
? [
{ name: 'linked-outside', isDirectory: true, isSymlink: true },
{ name: 'api', isDirectory: true, isSymlink: false }
]
: []
)
const result = await handlers.get('projectGroups:scanNested')!(null, {
path: '/srv/platform',
connectionId: 'conn-1'
})
expect((result as { repos: { path: string }[] }).repos.map((repo) => repo.path)).toEqual([
'/srv/platform/api'
])
})
it('prioritizes shallow sibling repositories before truncated SSH archive scans', async () => {
const archivedRepoNames = Array.from(
{ length: 101 },
(_, index) => `archived-service-${String(index + 1).padStart(3, '0')}`
)
const archivedRepoPaths = archivedRepoNames.map((name) => `/srv/platform/archive/${name}`)
const gitRepos = new Set(['/srv/platform/z-web-client', ...archivedRepoPaths])
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: gitRepos.has(path),
rootPath: null
}))
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
const repoPath = path.replace(/\/\.git$/, '')
if (path.endsWith('/.git') && gitRepos.has(repoPath)) {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => {
if (dirPath === '/srv/platform') {
return [
{ name: 'archive', isDirectory: true, isSymlink: false },
{ name: 'z-web-client', isDirectory: true, isSymlink: false }
]
}
if (dirPath === '/srv/platform/archive') {
return archivedRepoNames.map((name) => ({
name,
isDirectory: true,
isSymlink: false
}))
}
return []
})
const result = await handlers.get('projectGroups:scanNested')!(null, {
path: '/srv/platform',
connectionId: 'conn-1'
})
expect(result).toMatchObject({
selectedPath: '/srv/platform',
selectedPathKind: 'non_git_folder',
truncated: true
})
expect((result as { repos: { path: string }[] }).repos).toHaveLength(100)
expect((result as { repos: { path: string }[] }).repos[0].path).toBe(
'/srv/platform/z-web-client'
)
expect((result as { repos: { path: string }[] }).repos.map((repo) => repo.path)).toContain(
'/srv/platform/z-web-client'
)
})
it('returns partial SSH scan results after cancellation', async () => {
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: false, rootPath: null })
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === '/srv/platform/api/.git' || path === '/srv/platform/web/.git') {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform'
? [
{ name: 'api', isDirectory: true, isSymlink: false },
{ name: 'web', isDirectory: true, isSymlink: false }
]
: []
)
const event = {
sender: {
send: vi.fn((_channel: string, data: { scanId: string; scan: { repos: unknown[] } }) => {
if (data.scan.repos.length === 1) {
handlers.get('projectGroups:cancelNestedScan')!(null, { scanId: data.scanId })
}
})
}
}
const result = await handlers.get('projectGroups:scanNested')!(event, {
path: '/srv/platform',
connectionId: 'conn-1',
scanId: 'scan-1'
})
expect(result).toMatchObject({
selectedPath: '/srv/platform',
stopped: true,
repos: [{ path: '/srv/platform/api' }]
})
expect(event.sender.send).toHaveBeenCalledWith(
'projectGroups:scanNestedProgress',
expect.objectContaining({ scanId: 'scan-1' })
)
it('refuses SSH nested scans without reading the remote or the local filesystem', async () => {
await expect(
handlers.get('projectGroups:scanNested')!(null, {
path: '/srv/platform',
connectionId: 'conn-1'
})
).rejects.toThrow('ssh_connection_unavailable')
expect(mockFilesystemProvider.readDir).not.toHaveBeenCalled()
expect(isGitRepo).not.toHaveBeenCalled()
})
it('returns partial local scan results after cancellation', async () => {
@@ -25,16 +25,20 @@ vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleM
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
import { createRepoHandlerHarness } from './repos-remote-test-harness'
import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../shared/repo-search-limits'
import { getBaseRefDefault } from '../git/repo'
const { handleMock, mockStore, mockGitProvider, prepareLocalWorktreeRootForRepoMock } = reposMocks
const {
handleMock,
mockStore,
mockGitProvider,
gitExecFileAsyncMock,
prepareLocalWorktreeRootForRepoMock
} = reposMocks
beforeEach(() => {
clearGitCapabilityStateForTests()
@@ -51,6 +55,8 @@ describe('repos:getBaseRefDefault envelope', () => {
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
// Reset exec so a newly added test doesn't inherit the previous test's exec mock.
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockReset()
vi.mocked(getBaseRefDefault).mockClear()
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
@@ -91,44 +97,7 @@ describe('repos:getBaseRefDefault envelope', () => {
expect(result.remoteCount).toBe(1)
})
// Why: the handler resolves default-ref and remote-count in parallel, so dispatch on argv (not call order) to stay stable.
type ExecResponse = { stdout: string; stderr: string }
type ExecRule = {
matches: (argv: string[]) => boolean
respond: () => Promise<ExecResponse>
}
const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise<ExecResponse>) => {
return (argv: string[]) => {
for (const rule of rules) {
if (rule.matches(argv)) {
return rule.respond()
}
}
return Promise.reject(new Error(`unexpected exec call: ${argv.join(' ')}`))
}
}
const isRefSnapshot = (argv: string[]): boolean =>
argv[0] === 'for-each-ref' && argv.includes('--format=%(refname)%00%(symref)')
const isRemoteList = (argv: string[]): boolean => argv.length === 1 && argv[0] === 'remote'
it('returns envelope over SSH relay for remote repos', async () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
{
matches: isRefSnapshot,
respond: () =>
Promise.resolve({
stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n',
stderr: ''
})
},
{
matches: isRemoteList,
respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
}
])
)
it('returns { defaultBaseRef: null, remoteCount: 0 } for an SSH repo without running git', async () => {
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
@@ -136,82 +105,15 @@ describe('repos:getBaseRefDefault envelope', () => {
kind: 'git'
})
const result = (await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' })) as {
defaultBaseRef: string | null
remoteCount: number
}
const result = await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' })
expect(result.defaultBaseRef).toBe('origin/main')
expect(result.remoteCount).toBe(2)
})
it('returns defaultBaseRef even when remote-count lookup fails', async () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
{
matches: isRefSnapshot,
respond: () =>
Promise.resolve({
stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n',
stderr: ''
})
},
{
matches: isRemoteList,
respond: () => Promise.reject(new Error('relay exec failed'))
}
])
)
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
const result = (await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' })) as {
defaultBaseRef: string | null
remoteCount: number
}
// Why: default detection is independent of remote-count; a failing count falls back to 0 while the default still resolves.
expect(result.defaultBaseRef).toBe('origin/main')
expect(result.remoteCount).toBe(0)
})
it('uses the primary fallback over SSH when origin/HEAD is absent', async () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
{
matches: isRefSnapshot,
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/master\0\n', stderr: '' })
},
{
matches: isRemoteList,
respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' })
}
])
)
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
const result = (await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' })) as {
defaultBaseRef: string | null
remoteCount: number
}
expect(result.defaultBaseRef).toBe('origin/master')
expect(result.remoteCount).toBe(1)
expect(result).toEqual({ defaultBaseRef: null, remoteCount: 0 })
expect(mockGitProvider.exec).not.toHaveBeenCalled()
expect(getBaseRefDefault).not.toHaveBeenCalled()
})
})
describe('repos:searchBaseRefs SSH relay', () => {
describe('repos:searchBaseRefs on SSH repos', () => {
const { handlers, mockWindow, captureHandlers } = createRepoHandlerHarness()
beforeEach(() => {
@@ -220,6 +122,8 @@ describe('repos:searchBaseRefs SSH relay', () => {
mockStore.getRepo.mockReset()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockReset()
vi.mocked(getBaseRefDefault).mockClear()
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
@@ -240,17 +144,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
expect(mockGitProvider.exec).not.toHaveBeenCalled()
})
it('returns refs for an empty query so remote Branch pickers open populated', async () => {
const stdout = [
'refs/remotes/origin/main\0origin/main',
'refs/remotes/upstream/feature-x\0upstream/feature-x'
].join('\n')
mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => {
if (argv[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
}
return Promise.resolve({ stdout, stderr: '' })
})
it('returns [] for an SSH repo without running git', async () => {
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
@@ -258,254 +152,14 @@ describe('repos:searchBaseRefs SSH relay', () => {
kind: 'git'
})
const result = await handlers.get('repos:searchBaseRefs')!(null, {
const result = await handlers.get('repos:searchBaseRefDetails')!(null, {
repoId: 'r1',
query: ''
})
expect(result).toEqual(['origin/main', 'upstream/feature-x'])
expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
const [argv] = mockGitProvider.exec.mock.calls.find(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)!
expect(argv.some((arg: string) => arg.startsWith('--exclude=refs/remotes/'))).toBe(true)
expect(argv).toContain('--count=100')
expect(argv).toContain('refs/heads/**/**')
expect(argv).toContain('refs/heads/**/**/**')
expect(argv).toContain('refs/remotes/**/**')
expect(argv).toContain('refs/remotes/**/**/**')
})
it('sanitizes glob metacharacter-only queries into the empty-query branch list', async () => {
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: '***'
})
// Why: glob metacharacters are stripped, so the empty query intentionally lists refs.
const [argv] = mockGitProvider.exec.mock.calls.find(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)!
expect(argv.some((arg: string) => arg.startsWith('--exclude=refs/remotes/'))).toBe(true)
expect(argv).toContain('--count=100')
expect(argv).toContain('refs/heads/**/**')
expect(argv).toContain('refs/heads/**/**/**')
expect(argv).toContain('refs/remotes/**/**')
expect(argv).toContain('refs/remotes/**/**/**')
})
it('rejects invalid limits before building broad relay searches', async () => {
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
const result = await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: '',
limit: 0.5
query: 'main'
})
expect(result).toEqual([])
expect(mockGitProvider.exec).not.toHaveBeenCalled()
})
it('clamps oversized limits before building broad relay searches', async () => {
mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => {
if (argv[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\n', stderr: '' })
}
return Promise.resolve({
stdout: 'refs/remotes/origin/main\0origin/main',
stderr: ''
})
})
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
const result = await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: '',
limit: REPO_SEARCH_REFS_MAX_LIMIT + 1
})
expect(result).toEqual(['origin/main'])
const forEachRefCall = mockGitProvider.exec.mock.calls.find(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)
expect(forEachRefCall?.[0]).toContain('--count=4000')
})
it('retries without --exclude for older git on SSH hosts', async () => {
const stdout = [
'refs/remotes/origin/main\0origin/main',
'refs/remotes/origin/HEAD\0origin/HEAD'
].join('\n')
mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => {
if (argv[0] === 'remote') {
return Promise.resolve({ stdout: 'origin\n', stderr: '' })
}
if (argv.some((arg) => arg.startsWith('--exclude=refs/remotes/'))) {
return Promise.reject(
Object.assign(new Error("unknown option `exclude'"), {
stderr: "error: unknown option `exclude'"
})
)
}
return Promise.resolve({ stdout, stderr: '' })
})
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
const result = await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: '',
limit: 1
})
const repeatedResult = await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: '',
limit: 1
})
expect(result).toEqual(['origin/main'])
expect(repeatedResult).toEqual(['origin/main'])
const forEachRefCalls = mockGitProvider.exec.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)
expect(forEachRefCalls).toHaveLength(3)
expect(
(forEachRefCalls[0][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/'))
).toBe(true)
expect(
(forEachRefCalls[1][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/'))
).toBe(false)
expect(forEachRefCalls[1][0]).toContain('--count=104')
expect(
(forEachRefCalls[2][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/'))
).toBe(false)
})
it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => {
// Why: SSH globs all remotes and `**` crosses `/` so slash-named branches match a single-word query (issue #624).
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream' })
expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
const [argv, path] = mockGitProvider.exec.mock.calls.find(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)!
expect(path).toBe('/remote/repo')
expect(argv[0]).toBe('for-each-ref')
expect(argv).toContain('refs/heads/**/*upstream*')
expect(argv).toContain('refs/heads/**/*upstream*/**')
expect(argv).toContain('refs/remotes/**/*upstream*')
expect(argv).toContain('refs/remotes/**/*upstream*/**')
// Guard against regression to the old origin-only glob.
expect(argv).not.toContain('refs/remotes/origin/*upstream*')
expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo')
})
it('sends segmented argv for display-format queries like `upstream/main`', async () => {
// Why: a single `*<q>*` glob with a literal `/` makes SSH multi-segment queries silently match nothing (issue #624 shape).
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' })
expect(mockGitProvider.exec).toHaveBeenCalledTimes(3)
const forEachRefCalls = mockGitProvider.exec.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)
expect(forEachRefCalls).toHaveLength(2)
const segmentedArgv = forEachRefCalls[0][0] as string[]
const branchRootArgv = forEachRefCalls[1][0] as string[]
expect(segmentedArgv).toContain('refs/remotes/*upstream*/*main*')
expect(segmentedArgv).toContain('refs/heads/*upstream*/*main*')
expect(branchRootArgv).toContain('refs/remotes/*/upstream/main*')
expect(branchRootArgv).toContain('refs/heads/upstream/main*')
// Regression guard: a literal slash must never appear inside a single segmented glob (`*` doesn't cross `/`).
expect(segmentedArgv).not.toContain('refs/remotes/*upstream/main*/*')
expect(segmentedArgv).not.toContain('refs/remotes/*/*upstream/main*')
expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo')
})
it('parses NUL-delimited stdout and filters <remote>/HEAD pseudo-refs', async () => {
// Why: confirms the HEAD filter works for any remote, not just origin, on the SSH path.
const stdout = [
'refs/remotes/origin/main\0origin/main',
'refs/remotes/upstream/main\0upstream/main',
'refs/remotes/upstream/HEAD\0upstream/HEAD',
'refs/remotes/origin/HEAD\0origin/HEAD'
].join('\n')
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout, stderr: '' })
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
const result = (await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: 'main'
})) as string[]
expect(result).toEqual(['origin/main', 'upstream/main'])
expect(result).not.toContain('origin/HEAD')
expect(result).not.toContain('upstream/HEAD')
})
it('returns [] when the relay exec throws', async () => {
mockGitProvider.exec = vi.fn().mockRejectedValue(new Error('ssh connection dropped'))
mockStore.getRepo.mockReturnValue({
id: 'r1',
path: '/remote/repo',
connectionId: 'conn-1',
kind: 'git'
})
const result = await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: 'main'
})
// Why: transport failure falls back to an empty result set so the picker doesn't crash.
expect(result).toEqual([])
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('returns [] when the SSH provider is not connected', async () => {
+12 -35
View File
@@ -25,15 +25,19 @@ vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleM
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
import { createRepoHandlerHarness } from './repos-remote-test-harness'
const { handleMock, mockStore, mockGitProvider, prepareLocalWorktreeRootForRepoMock } = reposMocks
const {
handleMock,
mockStore,
mockGitProvider,
gitExecFileAsyncMock,
prepareLocalWorktreeRootForRepoMock
} = reposMocks
beforeEach(() => {
clearGitCapabilityStateForTests()
@@ -47,13 +51,14 @@ describe('repos:getGitUsername', () => {
captureHandlers(handleMock)
mockStore.getRepo.mockReset()
mockGitProvider.exec.mockReset()
gitExecFileAsyncMock.mockReset()
mockWindow.webContents.send.mockReset()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('uses explicit SSH username config instead of remote author identity', async () => {
it('returns no username for an SSH repo and never reads git config for it', async () => {
mockStore.getRepo.mockReturnValue({
id: 'repo-ssh',
path: '/remote/repo',
@@ -63,39 +68,11 @@ describe('repos:getGitUsername', () => {
kind: 'git',
connectionId: 'conn-1'
})
mockGitProvider.exec.mockImplementation(async (args: string[]) => {
if (args[0] === 'config' && args[1] === '--get') {
const valueByKey: Record<string, string> = {
'user.username': 'remote-login',
'user.email': 'remote-user@example.com',
'user.name': 'Remote User'
}
const value = valueByKey[args[2]]
if (value) {
return { stdout: `${value}\n`, stderr: '' }
}
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const username = await handlers.get('repos:getGitUsername')!(null, { repoId: 'repo-ssh' })
expect(username).toBe('remote-login')
expect(mockGitProvider.exec).toHaveBeenCalledWith(
['config', '--get', 'github.user'],
'/remote/repo'
)
expect(mockGitProvider.exec).toHaveBeenCalledWith(
['config', '--get', 'user.username'],
'/remote/repo'
)
expect(mockGitProvider.exec).not.toHaveBeenCalledWith(
['config', '--get', 'user.email'],
'/remote/repo'
)
expect(mockGitProvider.exec).not.toHaveBeenCalledWith(
['config', '--get', 'user.name'],
'/remote/repo'
)
expect(username).toBe('')
expect(mockGitProvider.exec).not.toHaveBeenCalled()
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})
+6 -55
View File
@@ -29,15 +29,8 @@ export type ReposIpcMocks = {
| 'getSshTargets',
ReposIpcSpy
> & { updateRepo: Mock<(repoId: string, updates: Record<string, unknown>) => unknown> }
mockGitProvider: Record<
'isGitRepo' | 'isGitRepoAsync' | 'clone' | 'listWorktrees' | 'getHostPlatform',
ReposIpcSpy
> & { exec: ReposGitArgvSpy }
mockFilesystemProvider: Record<
'readDir' | 'readFile' | 'stat' | 'createDir' | 'createDirNoClobber' | 'deletePath',
ReposIpcSpy
>
mockMultiplexer: Record<'request' | 'notify', ReposIpcSpy>
mockGitProvider: { exec: ReposGitArgvSpy }
mockFilesystemProvider: Record<'readDir', ReposIpcSpy>
gitSpawnMock: ReposIpcSpy
gitSpawnAfterWindowsEnvironmentReadyMock: ReposIpcSpy
gitExecFileAsyncMock: ReposGitArgvSpy
@@ -66,33 +59,12 @@ export function createReposIpcMocks(): ReposIpcMocks {
getSshTarget: vi.fn(),
getSshTargets: vi.fn().mockReturnValue([])
},
// Why: SSH targets are not served; suites assert these stand-ins are never reached.
mockGitProvider: {
isGitRepo: vi.fn().mockReturnValue(true),
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
clone: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
listWorktrees: vi.fn().mockResolvedValue([]),
getHostPlatform: vi.fn().mockReturnValue({
relayPlatform: 'linux-x64',
os: 'linux',
arch: 'x64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
})
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
},
mockFilesystemProvider: {
readDir: vi.fn().mockResolvedValue([]),
readFile: vi.fn().mockRejectedValue(new Error('not found')),
stat: vi.fn().mockRejectedValue(new Error('not found')),
createDir: vi.fn().mockResolvedValue(undefined),
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
deletePath: vi.fn().mockResolvedValue(undefined)
},
mockMultiplexer: {
request: vi.fn(),
notify: vi.fn()
readDir: vi.fn().mockResolvedValue([])
},
gitSpawnMock: vi.fn(),
gitSpawnAfterWindowsEnvironmentReadyMock: vi.fn(),
@@ -113,7 +85,7 @@ export function electronModuleMock(mocks: ReposIpcMocks): Record<string, unknown
}
}
// Why: use real pure helpers so SSH parity tests catch drift in DEFAULT_BASE_REF_PROBES / normalizeRefSearchQuery.
// Why: use real pure helpers; stub only what spawns git or touches the filesystem.
export function gitRepoModuleMock(actual: typeof RepoModule): Record<string, unknown> {
const isGitRepo = vi.fn().mockReturnValue(true)
const getGitRepoRoot = vi.fn((path: string) => path)
@@ -188,17 +160,6 @@ export function sshFilesystemDispatchModuleMock(mocks: ReposIpcMocks): Record<st
}
}
export function sshModuleMock(mocks: ReposIpcMocks): Record<string, unknown> {
return {
getActiveMultiplexer: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1') {
return mocks.mockMultiplexer
}
return undefined
})
}
}
export type RepoHandlerHarness = {
handlers: Map<string, (_event: unknown, args: unknown) => unknown>
mockWindow: { isDestroyed: () => boolean; webContents: { send: ReposIpcSpy } }
@@ -242,22 +203,12 @@ export function resetProjectGroupMocks(
mocks.mockStore.getRepos.mockReturnValue([])
mocks.mockFilesystemProvider.readDir.mockReset()
mocks.mockFilesystemProvider.readDir.mockResolvedValue([])
mocks.mockFilesystemProvider.readFile.mockReset()
mocks.mockFilesystemProvider.readFile.mockRejectedValue(new Error('not found'))
mocks.mockFilesystemProvider.stat.mockReset()
mocks.mockFilesystemProvider.stat.mockRejectedValue(new Error('not found'))
mocks.mockGitProvider.isGitRepoAsync.mockReset()
mocks.mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
mocks.mockGitProvider.listWorktrees.mockReset()
mocks.mockGitProvider.listWorktrees.mockResolvedValue([])
mocks.listWorktreeGraphMock.mockReset()
mocks.listWorktreeGraphMock.mockResolvedValue([])
vi.mocked(gitRepo.isGitRepo).mockReset()
vi.mocked(gitRepo.isGitRepo).mockReturnValue(true)
vi.mocked(gitRepo.getGitRepoRoot).mockReset()
vi.mocked(gitRepo.getGitRepoRoot).mockImplementation((path: string) => path)
mocks.mockMultiplexer.notify.mockReset()
mocks.mockMultiplexer.request.mockReset()
mocks.invalidateAuthorizedRootsCacheMock.mockReset()
mocks.prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
}
-767
View File
@@ -1,767 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import type * as GitRunner from '../git/runner'
import type * as RepoModule from '../git/repo'
const { reposMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./repos-remote-test-harness')
return { reposMocks: moduleMocks.createReposIpcMocks(), moduleMocks }
})
vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks))
vi.mock('../git/repo', async (importOriginal) =>
moduleMocks.gitRepoModuleMock(await importOriginal<typeof RepoModule>())
)
vi.mock('../git/runner', async (importOriginal) =>
moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal<typeof GitRunner>())
)
vi.mock('../git/worktree', () => moduleMocks.gitWorktreeModuleMock(reposMocks))
vi.mock('./registered-worktree-roots-cache', () =>
moduleMocks.registeredWorktreeRootsCacheModuleMock(reposMocks)
)
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)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { clearSubmodulePathsCacheForTests } from '../git/status'
import { createRepoHandlerHarness, waitForAssertion } from './repos-remote-test-harness'
const {
handleMock,
mockStore,
mockGitProvider,
mockFilesystemProvider,
mockMultiplexer,
gitExecFileAsyncMock,
gitSpawnMock,
prepareLocalWorktreeRootForRepoMock
} = reposMocks
beforeEach(() => {
clearGitCapabilityStateForTests()
resetSshProviderAuthorities()
})
describe('repos:addRemote', () => {
const { handlers, mockWindow, captureHandlers } = createRepoHandlerHarness()
beforeEach(() => {
captureHandlers(handleMock)
mockStore.getRepos.mockReset().mockReturnValue([])
mockStore.addRepo.mockReset()
mockStore.getSshTarget.mockReset()
mockStore.updateRepo.mockReset()
mockGitProvider.isGitRepoAsync.mockReset()
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
mockGitProvider.exec.mockReset()
mockGitProvider.exec.mockResolvedValue({ stdout: '', stderr: '' })
mockGitProvider.clone.mockReset()
mockGitProvider.clone.mockResolvedValue({ stdout: '', stderr: '' })
mockGitProvider.getHostPlatform.mockReset()
mockGitProvider.getHostPlatform.mockReturnValue({
relayPlatform: 'linux-x64',
os: 'linux',
arch: 'x64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
})
mockFilesystemProvider.stat.mockReset()
mockFilesystemProvider.stat.mockRejectedValue(new Error('not found'))
mockFilesystemProvider.createDirNoClobber.mockReset()
mockFilesystemProvider.createDirNoClobber.mockResolvedValue(undefined)
mockFilesystemProvider.deletePath.mockReset()
mockFilesystemProvider.deletePath.mockResolvedValue(undefined)
mockMultiplexer.request.mockReset()
mockMultiplexer.notify.mockReset()
gitSpawnMock.mockReset()
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
clearSubmodulePathsCacheForTests()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
gitSpawnMock.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
setImmediate(() => proc.emit('close', 0, null))
return proc
})
mockWindow.webContents.send.mockReset()
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('registers the repos:addRemote handler', () => {
expect(handlers.has('repos:addRemote')).toBe(true)
})
it('registers the repos:cloneRemote handler', () => {
expect(handlers.has('repos:cloneRemote')).toBe(true)
})
it('registers the repos:createRemote handler', () => {
expect(handlers.has('repos:createRemote')).toBe(true)
})
it('creates a remote repo with connectionId', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/project',
connectionId: 'conn-1',
kind: 'git',
displayName: 'project',
badgeColor: DEFAULT_REPO_BADGE_COLOR,
projectHostSetupMethod: 'imported-existing-folder'
})
)
expect(result).toHaveProperty('repo.id')
expect(result).toHaveProperty('repo.connectionId', 'conn-1')
expect(result).not.toHaveProperty('repo.externalWorktreeVisibility')
})
it('uses custom displayName when provided', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project',
displayName: 'My Server Repo'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
displayName: 'My Server Repo',
path: '/home/user/project'
})
)
expect(result).toHaveProperty('repo.displayName', 'My Server Repo')
})
it('clones a repo on an SSH target and registers the cloned path', async () => {
const result = await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(mockFilesystemProvider.createDir).toHaveBeenCalledWith('/home/user')
expect(mockGitProvider.clone).toHaveBeenCalledWith(
['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'],
'/home/user',
expect.objectContaining({
signal: expect.any(AbortSignal),
timeoutMs: 10 * 60_000,
onProgress: expect.any(Function)
})
)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/orca',
connectionId: 'conn-1',
kind: 'git',
displayName: 'orca',
badgeColor: DEFAULT_REPO_BADGE_COLOR
})
)
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
rootPath: '/home/user/orca'
})
expect(result).toHaveProperty('path', '/home/user/orca')
expect(result).toHaveProperty('connectionId', 'conn-1')
})
it('forwards SSH clone progress through the existing clone progress event', async () => {
mockGitProvider.clone.mockImplementationOnce(
async (
_args: string[],
_cwd: string,
options?: { onProgress?: (progress: { phase: string; percent: number }) => void }
) => {
options?.onProgress?.({ phase: 'Receiving objects', percent: 42 })
return { stdout: '', stderr: '' }
}
)
await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:clone-progress', {
phase: 'Receiving objects',
percent: 42
})
})
it('refuses a saved SSH project at the destination whose folder is not this clone', async () => {
mockStore.getRepos.mockReturnValue([
{
id: 'existing-id',
path: '/home/user/orca',
connectionId: 'conn-1',
displayName: 'orca',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
])
mockGitProvider.exec.mockRejectedValue(new Error('fatal: not a git repository'))
await expect(
handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
).rejects.toThrow('"orca" is already an Orca project')
expect(mockGitProvider.clone).not.toHaveBeenCalled()
})
it('re-registers the root after re-cloning into a saved SSH project whose folder was gone', async () => {
const existing = {
id: 'existing-id',
path: '/home/user/orca',
connectionId: 'conn-1',
displayName: 'orca',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git',
gitRemoteIdentity: {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'origin',
remoteUrl: 'https://github.com/stablyai/orca.git'
}
}
mockStore.getRepos.mockReturnValue([existing])
mockGitProvider.exec.mockRejectedValue(new Error('fatal: not a git repository'))
const result = await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)
expect(result).toBe(existing)
expect(mockStore.addRepo).not.toHaveBeenCalled()
// Without this the relay never learns the path came back, so sessions there stay unauthorized.
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
rootPath: '/home/user/orca'
})
})
it('upgrades an existing SSH folder repo after cloning into that path', async () => {
const existing = {
id: 'existing-folder',
path: '/home/user/orca',
connectionId: 'conn-1',
displayName: 'orca',
badgeColor: '#fff',
addedAt: 1000,
kind: 'folder'
}
const updated = { ...existing, kind: 'git' }
mockStore.getRepos.mockReturnValue([existing])
mockStore.updateRepo.mockReturnValue(updated)
const result = await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(mockGitProvider.clone).toHaveBeenCalledWith(
['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'],
'/home/user',
expect.objectContaining({
signal: expect.any(AbortSignal),
timeoutMs: 10 * 60_000,
onProgress: expect.any(Function)
})
)
expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', {
kind: 'git',
projectHostSetupMethod: 'cloned'
})
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(result).toBe(updated)
})
it('does not delete a fresh SSH clone target after git clone fails', async () => {
mockGitProvider.clone.mockRejectedValueOnce(new Error('repository not found'))
mockFilesystemProvider.stat.mockRejectedValueOnce(new Error('not found'))
await expect(
handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
).rejects.toThrow('repository not found')
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled()
})
it('rejects concurrent SSH clones to the same destination', async () => {
let releaseClone!: () => void
mockGitProvider.clone.mockImplementationOnce(
async () =>
new Promise<{ stdout: string; stderr: string }>((resolve) => {
releaseClone = () => resolve({ stdout: '', stderr: '' })
})
)
const firstClone = handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1))
await expect(
handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
).rejects.toThrow('A clone is already in progress for this SSH destination')
releaseClone()
await firstClone
})
it('resolves SSH clone destinations under home before validating the path', async () => {
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' })
await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '~/projects'
})
expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', {
path: '~/projects'
})
expect(mockGitProvider.clone).toHaveBeenCalledWith(
['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'],
'/home/ubuntu/projects',
expect.any(Object)
)
})
it('does not clean up a pre-existing SSH clone target after git clone fails', async () => {
mockGitProvider.clone.mockRejectedValueOnce(new Error('destination already exists'))
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
await expect(
handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
).rejects.toThrow('destination already exists')
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled()
})
it('aborts an active SSH clone and reports the abort without deleting pre-existing targets', async () => {
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
mockGitProvider.clone.mockImplementationOnce(
async (_args: string[], _cwd: string, options?: { signal?: AbortSignal }) =>
new Promise<{ stdout: string; stderr: string }>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(new Error('aborted by test')))
})
)
const clonePromise = handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1))
await handlers.get('repos:cloneAbort')!(null, undefined)
await expect(clonePromise).rejects.toThrow('Clone aborted')
const options = mockGitProvider.clone.mock.calls[0][2] as { signal: AbortSignal }
expect(options.signal.aborted).toBe(true)
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled()
})
it('rejects SSH clone destinations that are not absolute host paths', async () => {
await expect(
handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: 'relative/path'
})
).rejects.toThrow('Clone destination must be an absolute path on the SSH host')
expect(mockGitProvider.clone).not.toHaveBeenCalled()
})
it('creates a new git project on an SSH target', async () => {
const result = await handlers.get('repos:createRemote')!(null, {
connectionId: 'conn-1',
parentPath: '/home/user',
name: 'created',
kind: 'git'
})
expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/created')
expect(mockGitProvider.exec).toHaveBeenCalledWith(['init'], '/home/user/created')
expect(mockGitProvider.exec).toHaveBeenCalledWith(
['commit', '--allow-empty', '-m', 'Initial commit'],
'/home/user/created'
)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/created',
connectionId: 'conn-1',
kind: 'git',
displayName: 'created'
})
)
expect(result).toHaveProperty('repo.path', '/home/user/created')
expect(result).toHaveProperty('repo.connectionId', 'conn-1')
})
it('resolves SSH create parents under home before validating the path', async () => {
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' })
const result = await handlers.get('repos:createRemote')!(null, {
connectionId: 'conn-1',
parentPath: '~/projects',
name: 'created',
kind: 'folder'
})
expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', {
path: '~/projects'
})
expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith(
'/home/ubuntu/projects/created'
)
expect(result).toHaveProperty('repo.path', '/home/ubuntu/projects/created')
})
it('creates a new folder project on an SSH target without git init', async () => {
const result = await handlers.get('repos:createRemote')!(null, {
connectionId: 'conn-1',
parentPath: '/home/user',
name: 'notes',
kind: 'folder'
})
expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/notes')
expect(mockGitProvider.exec).not.toHaveBeenCalled()
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/notes',
connectionId: 'conn-1',
kind: 'folder',
displayName: 'notes'
})
)
expect(result).toHaveProperty('repo.kind', 'folder')
})
it('rejects SSH create parent paths that are not absolute host paths', async () => {
const result = await handlers.get('repos:createRemote')!(null, {
connectionId: 'conn-1',
parentPath: 'relative/path',
name: 'created',
kind: 'git'
})
expect(result).toEqual({ error: 'Parent directory must be an absolute path on the SSH host' })
expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled()
expect(mockGitProvider.exec).not.toHaveBeenCalled()
})
it('rejects non-empty existing SSH create targets', async () => {
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
mockFilesystemProvider.readDir.mockResolvedValueOnce([
{ name: 'package.json', isDirectory: false, isSymlink: false }
])
const result = await handlers.get('repos:createRemote')!(null, {
connectionId: 'conn-1',
parentPath: '/home/user',
name: 'created',
kind: 'git'
})
expect(result).toEqual({
error: '"created" already exists at this location and is not empty.'
})
expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled()
expect(mockGitProvider.exec).not.toHaveBeenCalled()
})
it('removes a newly created SSH directory when git init fails', async () => {
mockGitProvider.exec.mockRejectedValueOnce(new Error('git init failed'))
const result = await handlers.get('repos:createRemote')!(null, {
connectionId: 'conn-1',
parentPath: '/home/user',
name: 'created',
kind: 'git'
})
expect(result).toEqual({ error: 'Failed to initialize git repository: git init failed' })
expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created', true)
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('preserves an existing empty SSH directory and removes only .git when commit fails', async () => {
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
mockFilesystemProvider.readDir.mockResolvedValueOnce([])
mockGitProvider.exec
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(new Error('Please tell me who you are'))
const result = await handlers.get('repos:createRemote')!(null, {
connectionId: 'conn-1',
parentPath: '/home/user',
name: 'created',
kind: 'git'
})
expect(result).toEqual({
error:
'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.'
})
expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created/.git', true)
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalledWith('/home/user/created', true)
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('returns existing repo if same connectionId and path already added', async () => {
const existing = {
id: 'existing-id',
path: '/home/user/project',
connectionId: 'conn-1',
displayName: 'project',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project'
})
expect(result).toEqual({ repo: existing })
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('throws when SSH connection is not found', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'unknown-conn',
remotePath: '/home/user/project'
})
expect(result).toEqual({ error: 'SSH connection "unknown-conn" not found or not connected' })
})
it('throws when remote path is not a git repo', async () => {
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ isRepo: false, rootPath: null })
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/documents'
})
expect(result).toEqual({ error: 'Not a valid git repository: /home/user/documents' })
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('adds as folder when kind is explicitly set', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/documents',
kind: 'folder'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'folder',
path: '/home/user/documents',
badgeColor: DEFAULT_REPO_BADGE_COLOR
})
)
expect(result).toHaveProperty('repo.kind', 'folder')
})
it('uses rootPath from git detection when available', async () => {
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
isRepo: true,
rootPath: '/home/user/project'
})
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project/src'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'git',
path: '/home/user/project'
})
)
expect(result).toHaveProperty('repo.path', '/home/user/project')
})
it('uses the resolved git root basename for the default remote display name', async () => {
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
isRepo: true,
rootPath: '/home/user/project'
})
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project/src'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/project',
displayName: 'project'
})
)
expect(result).toHaveProperty('repo.displayName', 'project')
})
it('derives default remote display names from Windows path separators', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: 'C:\\Users\\alice\\project',
kind: 'folder'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: 'C:\\Users\\alice\\project',
displayName: 'project'
})
)
expect(result).toHaveProperty('repo.displayName', 'project')
})
it('notifies renderer when remote repo is added', async () => {
await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project'
})
expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed')
})
it('resolves ~ to absolute path via relay and uses SSH target label', async () => {
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' })
mockStore.getSshTarget.mockReturnValueOnce({
id: 'conn-1',
label: 'ubuntu-box',
host: '192.168.1.100',
port: 22,
username: 'user'
})
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '~'
})
expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { path: '~' })
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
displayName: 'ubuntu-box',
path: '/home/ubuntu'
})
)
expect(result).toHaveProperty('repo.displayName', 'ubuntu-box')
expect(result).toHaveProperty('repo.path', '/home/ubuntu')
})
it('resolves ~/subdir to absolute path via relay', async () => {
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/subdir' })
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '~/subdir'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/ubuntu/subdir',
displayName: 'subdir'
})
)
expect(result).toHaveProperty('repo.path', '/home/ubuntu/subdir')
})
it('returns an existing SSH repo when a selected subdirectory resolves to the repo root', async () => {
const existing = {
id: 'existing-id',
path: '/home/user/orca',
connectionId: 'conn-1',
displayName: 'orca',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
isRepo: true,
rootPath: '/home/user/orca'
})
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/orca/src'
})
expect(result).toEqual({ repo: existing })
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('ignores SSH target label when custom displayName is provided', async () => {
mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' })
mockStore.getSshTarget.mockReturnValueOnce({
id: 'conn-1',
label: 'ubuntu-box',
host: '192.168.1.100',
port: 22,
username: 'user'
})
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '~',
displayName: 'My Home'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
displayName: 'My Home',
path: '/home/ubuntu'
})
)
expect(result).toHaveProperty('repo.displayName', 'My Home')
})
})
-3
View File
@@ -52,16 +52,13 @@ export function registerRepoHandlers(
ipcMain.removeHandler('repos:pickDirectory')
ipcMain.removeHandler('repos:clone')
ipcMain.removeHandler('repos:cloneAbort')
ipcMain.removeHandler('repos:cloneRemote')
ipcMain.removeHandler('repos:isGitAvailable')
ipcMain.removeHandler('repos:getDefaultCreateProjectParent')
ipcMain.removeHandler('repos:getGitUsername')
ipcMain.removeHandler('repos:getBaseRefDefault')
ipcMain.removeHandler('repos:searchBaseRefs')
ipcMain.removeHandler('repos:searchBaseRefDetails')
ipcMain.removeHandler('repos:addRemote')
ipcMain.removeHandler('repos:create')
ipcMain.removeHandler('repos:createRemote')
ipcMain.removeHandler('sparsePresets:list')
ipcMain.removeHandler('sparsePresets:save')
ipcMain.removeHandler('sparsePresets:remove')
+4 -114
View File
@@ -8,20 +8,7 @@ import {
} from '../../../shared/repo-search-limits'
import { isFolderRepo } from '../../../shared/repo-kind'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import {
getBaseRefDefault,
getRemoteCount,
normalizeRefSearchQuery,
parseAndFilterSearchRefDetails,
parseRemoteCount,
resolveDefaultBaseRefViaExec,
buildSearchBaseRefsArgv,
isForEachRefExcludeUnsupportedError,
mergeBaseRefSearchResultGroups,
searchBaseRefDetails
} from '../../git/repo'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { getSshGitCapabilityCache } from '../../git/git-capability-state'
import { getBaseRefDefault, getRemoteCount, searchBaseRefDetails } from '../../git/repo'
export function registerBaseRefQueryHandlers(store: Store): void {
ipcMain.handle(
@@ -35,48 +22,9 @@ export function registerBaseRefQueryHandlers(store: Store): void {
// Why: folder repos have no git state for a base ref; return null + 0 so the renderer skips a fabricated default.
return { defaultBaseRef: null, remoteCount: 0 }
}
// Why: remote repos need the relay to resolve symbolic-ref where the git data lives.
// Why: an SSH repo's git data lives on a host this process does not serve.
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
return { defaultBaseRef: null, remoteCount: 0 }
}
// Why: delegate to shared resolveDefaultBaseRefViaExec; log symbolic-ref failures here to keep the SSH transport diagnostic it otherwise swallows.
const resolveDefault = async (): Promise<string | null> => {
return resolveDefaultBaseRefViaExec(async (argv) => {
try {
return await provider.exec(argv, repo.path)
} catch (err) {
if (argv[0] === 'symbolic-ref') {
console.warn('[repos:getBaseRefDefault] SSH symbolic-ref failed', {
path: repo.path,
err
})
}
throw err
}
})
}
const resolveRemoteCount = async (): Promise<number> => {
try {
const remotesResult = await provider.exec(['remote'], repo.path)
return parseRemoteCount(remotesResult.stdout)
} catch (err) {
// Why: 0 = unknown sentinel that suppresses the multi-remote hint.
console.warn('[repos:getBaseRefDefault] SSH git remote count failed', {
path: repo.path,
err
})
return 0
}
}
const [defaultBaseRef, remoteCount] = await Promise.all([
resolveDefault(),
resolveRemoteCount()
])
return { defaultBaseRef, remoteCount }
return { defaultBaseRef: null, remoteCount: 0 }
}
// Why: run in parallel; a remote-count failure must not break default detection.
const [defaultBaseRef, remoteCount] = await Promise.all([
@@ -123,66 +71,8 @@ async function searchBaseRefDetailsForRepo(
// Keep the public IPC shape forgiving while bounding Git and retained results
// for callers that request an unusually large page.
const limit = clampRepoSearchRefsLimit(requestedLimit)
// Why: remote repos need the relay to list branches on the remote host.
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
return []
}
// Why: strip glob metacharacters to prevent glob injection (mirrors local normalizeRefSearchQuery).
const normalizedQuery = normalizeRefSearchQuery(args.query)
try {
// Why: argv lives in buildSearchBaseRefsArgv so SSH and local paths cannot drift.
const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
const remotes = remotesResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const capabilities = getSshGitCapabilityCache(provider)
const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise<string> => {
return capabilities.runWithFallback(
'for-each-ref-exclude',
async () =>
(
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout,
async () =>
(
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout,
isForEachRefExcludeUnsupportedError
)
}
// Why: delegate the parse/filter/dedup/limit pipeline to the shared helper so SSH and local paths cannot diverge.
const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0)
if (searchTokens.length > 1) {
const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')])
return mergeBaseRefSearchResultGroups(
results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)),
limit
)
}
return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes)
} catch (err) {
console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', {
path: repo.path,
err
})
return []
}
return []
}
return searchBaseRefDetails(repo.path, args.query, limit)
}
@@ -13,11 +13,9 @@ import {
resolveNestedRepoSelection
} from '../../project-groups/nested-repo-import'
import { createNestedRepoImportTargetResolver } from '../../project-groups/nested-repo-import-target'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { getConnectionExecutionHostId } from '../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect'
import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation'
import { getActiveMultiplexer } from '../ssh'
import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache'
import { emitRepoAdded } from './repo-added-telemetry'
import { notifyReposChanged } from './repos-changed-notification'
@@ -68,31 +66,21 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store
for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) {
try {
let importRepoPath = repoPath
// Why: an SSH target is not served here; never inspect its path with local git.
if (args.connectionId) {
const gitProvider = getSshGitProvider(args.connectionId)
const check = gitProvider ? await gitProvider.isGitRepoAsync(repoPath) : null
if (!gitProvider || !check?.isRepo) {
results.push({
path: repoPath,
status: 'failed',
error: 'Not a valid git repository'
})
continue
}
importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider)
} else {
await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath })
if (!(await isGitRepoAsync(repoPath))) {
results.push({
path: repoPath,
status: 'failed',
error: 'Not a valid git repository'
})
continue
}
importRepoPath = await importTargetResolver.resolveLocal(repoPath)
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
continue
}
await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath })
if (!(await isGitRepoAsync(repoPath))) {
results.push({
path: repoPath,
status: 'failed',
error: 'Not a valid git repository'
})
continue
}
const importRepoPath = await importTargetResolver.resolveLocal(repoPath)
const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath)
const alreadyImportedProjectId =
importedProjectIdsByRepoPath.get(normalizedImportRepoPath)
@@ -123,7 +111,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store
const detected = await detectRepoIconAndUpstream({
repoPath: importRepoPath,
kind: 'git',
executionHostId: getConnectionExecutionHostId(args.connectionId)
executionHostId: LOCAL_EXECUTION_HOST_ID
})
const repo: Repo = {
id: randomUUID(),
@@ -133,7 +121,6 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store
...detected,
addedAt: Date.now(),
kind: 'git',
...(args.connectionId ? { connectionId: args.connectionId } : {}),
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: 'imported-existing-folder',
...(group
@@ -145,11 +132,6 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store
}
store.addRepo(repo)
await prepareLocalWorktreeRootForRepo(store, repo)
if (args.connectionId) {
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
rootPath: importRepoPath
})
}
importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id)
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
// Why: reaches here only after the isGitRepoAsync guard above confirmed a git repo, so always true.
+3 -67
View File
@@ -1,13 +1,10 @@
import type { IpcMainInvokeEvent } from 'electron'
import { isAbsolute, posix } from 'node:path'
import { isAbsolute } from 'node:path'
import type { z } from 'zod'
import type { NestedRepoScanResult } from '../../../shared/project-group-types'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner'
import { scanNestedRepos } from '../../project-groups/nested-repo-discovery'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { getActiveMultiplexer } from '../ssh'
import type { ProjectGroupScanNestedArgs } from './repo-ipc-arg-schemas'
export const activeNestedRepoScans = new Map<string, AbortController>()
@@ -72,23 +69,6 @@ export function getCompletedNestedRepoScan(args: {
return completed.scan
}
async function resolveSshProjectGroupPath(connectionId: string, path: string): Promise<string> {
if (path === '~' || path === '~/' || path.startsWith('~/')) {
const mux = getActiveMultiplexer(connectionId)
if (mux) {
try {
const result = (await mux.request('session.resolveHome', { path })) as {
resolvedPath: string
}
return result.resolvedPath
} catch {
return path
}
}
}
return path
}
export async function scanNestedReposForIpc(args: {
path: string
connectionId?: string
@@ -109,52 +89,8 @@ export async function scanNestedReposForIpc(args: {
onProgress: args.onProgress
})
}
const gitProvider = getSshGitProvider(args.connectionId)
const fsProvider = getSshFilesystemProvider(args.connectionId)
if (!gitProvider || !fsProvider) {
throw new Error('ssh_connection_unavailable')
}
const resolvedPath = await resolveSshProjectGroupPath(args.connectionId, args.path)
return scanNestedRepos({
path: resolvedPath,
options: args.options,
signal: args.signal,
onProgress: args.onProgress,
filesystem: {
readDirectory: async (dirPath) =>
(await fsProvider.readDir(dirPath)).map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
isSymlink: entry.isSymlink
})),
readTextFile: async (filePath) => (await fsProvider.readFile(filePath)).content,
joinPath: (parentPath, childName) => posix.join(parentPath, childName),
basename: (path) => posix.basename(path),
hasGitMarker: async (path) => {
try {
const marker = await fsProvider.stat(posix.join(path, '.git'))
if (marker.type === 'directory' || marker.type === 'file') {
return true
}
} catch {
// Continue to cheap bare-repository marker checks below.
}
const [head, objects, refs] = await Promise.all([
fsProvider.stat(posix.join(path, 'HEAD')).catch(() => null),
fsProvider.stat(posix.join(path, 'objects')).catch(() => null),
fsProvider.stat(posix.join(path, 'refs')).catch(() => null)
])
return head?.type === 'file' && objects?.type === 'directory' && refs?.type === 'directory'
},
isSelectedPathGitRepo: async (path) => {
try {
return (await gitProvider.isGitRepoAsync(path)).isRepo
} catch {
return false
}
}
}
})
// Why: this process serves no SSH target, so a remote scan has nothing to read.
throw new Error('ssh_connection_unavailable')
}
export async function runNestedRepoScanForIpc(
@@ -27,7 +27,7 @@ import {
parseProjectGroupIpcArgs
} from './repo-ipc-arg-schemas'
import { addLocalRepoFromPath } from './local-repo-registration'
import { addRemoteRepoFromPath } from './remote-repo-registration'
import { sshTargetNotConnectedMessage } from '../../providers/ssh-host-not-served-error'
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
@@ -152,12 +152,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor
parsedHost.kind === 'local'
? await addLocalRepoFromPath(store, args.path, args.kind)
: parsedHost.kind === 'ssh'
? await addRemoteRepoFromPath(store, {
connectionId: parsedHost.targetId,
remotePath: args.path,
displayName: args.displayName,
kind: args.kind
})
? { error: sshTargetNotConnectedMessage(parsedHost.targetId) }
: {
error:
'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.'
-18
View File
@@ -1,18 +0,0 @@
import { getActiveMultiplexer } from '../../ssh/ssh-target-registry'
export async function resolveRemoteHomePath(connectionId: string, path: string): Promise<string> {
if (path !== '~' && path !== '~/' && !path.startsWith('~/')) {
return path
}
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
return path
}
try {
const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string }
return result.resolvedPath
} catch {
// Why: older relays may not support this; return the original path so callers surface their own validation error.
return path
}
}
-168
View File
@@ -1,168 +0,0 @@
import type { BrowserWindow } from 'electron'
import type { Store } from '../../persistence'
import type { Repo } from '../../../shared/repo-types'
import { isFolderRepo } from '../../../shared/repo-kind'
import {
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../../shared/cross-platform-path'
import { getGitCloneFailureMessage } from '../../../shared/git-clone-failure-message'
import { deriveCloneRepoNameFromUrl } from '../../git/repo-clone-path'
import { reuseSavedCloneTarget } from '../../git/saved-clone-target'
import { getRepoSshConnectionId, toSshExecutionHostId } from '../../../shared/execution-host'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { joinRemotePath } from '../../ssh/ssh-remote-platform'
import { getActiveMultiplexer } from '../ssh'
import { emitRepoAdded } from './repo-added-telemetry'
import { addRemoteRepoFromPath } from './remote-repo-registration'
import { resolveRemoteHomePath } from './remote-home-path'
type ActiveRemoteCloneMetadata = {
connectionId: string
clonePath: string
controller: AbortController
}
let activeRemoteClone: ActiveRemoteCloneMetadata | null = null
const remoteCloneInFlightByPath = new Set<string>()
export async function cloneRemoteRepo(
store: Store,
mainWindow: BrowserWindow,
args: {
connectionId: string
url: string
destination: string
}
): Promise<Repo> {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
throw new Error(`SSH connection "${args.connectionId}" not found or not connected`)
}
const fsProvider = getSshFilesystemProvider(args.connectionId)
if (!fsProvider) {
throw new Error(`SSH connection "${args.connectionId}" not found or not connected`)
}
const host = gitProvider.getHostPlatform?.()
if (!host) {
throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.')
}
const trimmedDestination = await resolveRemoteHomePath(args.connectionId, args.destination.trim())
if (!isRuntimePathAbsolute(trimmedDestination, host.pathFlavor)) {
throw new Error('Clone destination must be an absolute path on the SSH host')
}
const repoName = deriveCloneRepoNameFromUrl(args.url.trim())
const clonePath = joinRemotePath(host, trimmedDestination, repoName)
if (relativePathInsideRoot(trimmedDestination, clonePath) === null) {
throw new Error('Clone path must be inside the destination directory')
}
const clonePathKey = normalizeRuntimePathForComparison(clonePath)
const findSaved = (): Repo | undefined =>
store.getRepos().find((repo) => {
return (
getRepoSshConnectionId(repo) === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) === clonePathKey
)
})
const remoteCloneKey = `${args.connectionId}:${clonePathKey}`
if (remoteCloneInFlightByPath.has(remoteCloneKey)) {
throw new Error('A clone is already in progress for this SSH destination')
}
const controller = new AbortController()
const metadata: ActiveRemoteCloneMetadata = {
connectionId: args.connectionId,
clonePath,
controller
}
activeRemoteClone = metadata
remoteCloneInFlightByPath.add(remoteCloneKey)
try {
// Why: after the in-flight guard, so a retry never inspects a clone that is still running.
const reused = await reuseSavedCloneTarget(
findSaved,
args.url.trim(),
toSshExecutionHostId(args.connectionId),
controller.signal
)
if (reused) {
emitRepoAdded('clone_url', true)
return reused
}
// Why: match local clone by creating the parent first, or a fresh remote parent surfaces as spawn ENOENT.
await fsProvider.createDir(trimmedDestination)
// Why: the SSH relay runs git argv, not a shell; use the repo folder name so git creates it under the chosen parent.
await gitProvider.clone(
['clone', '--progress', '--', args.url.trim(), repoName],
trimmedDestination,
{
signal: controller.signal,
timeoutMs: 10 * 60_000,
onProgress: (progress) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:clone-progress', progress)
}
}
}
)
} catch (err) {
if (controller.signal.aborted) {
throw new Error('Clone aborted')
}
const message = err instanceof Error ? err.message : String(err)
if (message.startsWith('Clone failed:')) {
throw new Error(`Clone failed: ${getGitCloneFailureMessage(message, { clonePath })}`)
}
throw err
} finally {
if (activeRemoteClone === metadata) {
activeRemoteClone = null
}
remoteCloneInFlightByPath.delete(remoteCloneKey)
}
const existing = findSaved()
if (existing) {
if (isFolderRepo(existing)) {
const updated = store.updateRepo(existing.id, {
kind: 'git',
projectHostSetupMethod: 'cloned'
})
if (updated) {
emitRepoAdded('clone_url', false)
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
rootPath: clonePath
})
return updated
}
} else {
// Why: git re-created this project's folder, and `addRemoteRepoFromPath` returns an existing
// project without registering the root, so the relay would never learn the path came back.
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
rootPath: clonePath
})
emitRepoAdded('clone_url', true)
return existing
}
}
const result = await addRemoteRepoFromPath(store, {
connectionId: args.connectionId,
remotePath: clonePath,
kind: 'git',
setupMethod: 'cloned'
})
if ('error' in result) {
throw new Error(result.error)
}
emitRepoAdded('clone_url', result.alreadyExisted)
return result.repo
}
// Why: `activeRemoteClone` is module-scoped and cannot be reassigned across a module boundary.
export function abortActiveRemoteClone(): void {
if (activeRemoteClone) {
activeRemoteClone.controller.abort()
activeRemoteClone = null
}
}
-152
View File
@@ -1,152 +0,0 @@
import type { Store } from '../../persistence'
import type { Repo } from '../../../shared/repo-types'
import {
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../../shared/cross-platform-path'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { joinRemotePath } from '../../ssh/ssh-remote-platform'
import { emitRepoAdded } from './repo-added-telemetry'
import { addRemoteRepoFromPath } from './remote-repo-registration'
import { resolveRemoteHomePath } from './remote-home-path'
export async function createRemoteRepo(
store: Store,
args: {
connectionId: string
parentPath: string
name: string
kind: 'git' | 'folder'
}
): Promise<{ repo: Repo } | { error: string }> {
const name = args.name?.trim() ?? ''
const parentPath = await resolveRemoteHomePath(args.connectionId, args.parentPath?.trim() ?? '')
const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git'
if (!name) {
return { error: 'Name cannot be empty' }
}
if (/[\\/]/.test(name) || name === '.' || name === '..') {
return { error: 'Name cannot contain slashes or be "." / ".."' }
}
if (!parentPath) {
return { error: 'Parent directory is required' }
}
const gitProvider = getSshGitProvider(args.connectionId)
const fsProvider = getSshFilesystemProvider(args.connectionId)
if (!gitProvider || !fsProvider) {
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
}
const host = gitProvider.getHostPlatform?.()
if (!host) {
return { error: 'SSH host platform is unavailable. Reconnect the SSH target before creating.' }
}
if (!isRuntimePathAbsolute(parentPath, host.pathFlavor)) {
return { error: 'Parent directory must be an absolute path on the SSH host' }
}
const targetPath = joinRemotePath(host, parentPath, name)
if (relativePathInsideRoot(parentPath, targetPath) === null) {
return { error: 'Project path must be inside the parent directory' }
}
const targetPathKey = normalizeRuntimePathForComparison(targetPath)
const existing = store.getRepos().find((repo) => {
return (
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) === targetPathKey
)
})
if (existing) {
emitRepoAdded('folder_picker', true)
return { repo: existing }
}
let createdDir = false
let targetExists = false
try {
await fsProvider.stat(targetPath)
targetExists = true
} catch {
targetExists = false
}
if (targetExists) {
try {
const entries = await fsProvider.readDir(targetPath)
if (entries.length > 0) {
return { error: `"${name}" already exists at this location and is not empty.` }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { error: `Failed to read directory: ${message}` }
}
} else {
try {
await fsProvider.createDirNoClobber(targetPath)
createdDir = true
} catch (err) {
const raceWinner = store.getRepos().find((repo) => {
return (
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) === targetPathKey
)
})
if (raceWinner) {
return { repo: raceWinner }
}
const message = err instanceof Error ? err.message : String(err)
return { error: `Failed to create directory: ${message}` }
}
}
if (repoKind === 'git') {
let step: 'init' | 'commit' = 'init'
try {
await gitProvider.exec(['init'], targetPath)
step = 'commit'
await gitProvider.exec(['commit', '--allow-empty', '-m', 'Initial commit'], targetPath)
} catch (err) {
if (createdDir) {
await fsProvider.deletePath(targetPath, true).catch(() => undefined)
} else if (step === 'commit') {
await fsProvider
.deletePath(joinRemotePath(host, targetPath, '.git'), true)
.catch(() => undefined)
}
const message = err instanceof Error ? err.message : String(err)
if (step === 'commit' && /Please tell me who you are|user\.name|user\.email/i.test(message)) {
return {
error:
'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.'
}
}
const stepLabel =
step === 'init' ? 'Failed to initialize git repository' : 'Failed to create initial commit'
return { error: `${stepLabel}: ${message}` }
}
}
const raceWinner = store.getRepos().find((repo) => {
return (
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) === targetPathKey
)
})
if (raceWinner) {
emitRepoAdded('folder_picker', true)
return { repo: raceWinner }
}
const result = await addRemoteRepoFromPath(store, {
connectionId: args.connectionId,
remotePath: targetPath,
kind: repoKind,
displayName: name
})
if ('error' in result) {
return result
}
emitRepoAdded('folder_picker', result.alreadyExisted)
return { repo: result.repo }
}
@@ -1,124 +0,0 @@
// Registration is now the runtime's SSH path too (`projectHostSetup.setupExistingFolder --host
// ssh:*`), so what it stamps decides what every downstream host resolver can read. It minted
// `connectionId`-only rows, leaving the unified spelling permanently empty, and deduped by raw
// `connectionId`, which cannot see a row stamped `executionHostId: 'ssh:*'`.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../shared/repo-types'
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
vi.mock('../../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('../../repo-icon-autodetect', () => ({
detectRepoIconAndUpstream: vi.fn(async () => ({}))
}))
vi.mock('../../ssh/ssh-target-registry', () => ({
getActiveMultiplexer: vi.fn(() => null)
}))
vi.mock('./remote-home-path', () => ({
resolveRemoteHomePath: vi.fn(async (_connectionId: string, path: string) => path)
}))
import { addRemoteRepoFromPath } from './remote-repo-registration'
function makeStore(repos: Repo[]) {
return {
getRepos: () => repos,
getSshTarget: () => undefined,
addRepo: (repo: Repo) => {
repos.push(repo)
}
}
}
describe('addRemoteRepoFromPath', () => {
beforeEach(() => {
getSshGitProviderMock.mockReset()
getSshGitProviderMock.mockReturnValue({
isGitRepoAsync: vi.fn(async () => ({ isRepo: true, rootPath: '/srv/app' }))
})
})
it('stamps the unified execution-host spelling alongside the legacy connection id', async () => {
const repos: Repo[] = []
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'm4air',
remotePath: '/srv/app'
})
expect('error' in result).toBe(false)
const repo = (result as { repo: Repo }).repo
expect(repo.connectionId).toBe('m4air')
expect(repo.executionHostId).toBe('ssh:m4air')
})
it('dedupes against a row that names the host in the unified spelling only', async () => {
const existing = {
id: 'existing',
path: '/srv/app',
displayName: 'app',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'ssh:m4air'
} as Repo
const repos: Repo[] = [existing]
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'm4air',
remotePath: '/srv/app'
})
expect(result).toEqual({ repo: existing, alreadyExisted: true })
expect(repos).toHaveLength(1)
})
it('does not dedupe onto a row on a different SSH host at the same path', async () => {
// Two hosts can both hold /srv/app. Matching on path alone registers one host's repo as the
// other's — the mirror image of the id-only lookup this change removes.
const repos: Repo[] = [
{
id: 'openclaw-row',
path: '/srv/app',
displayName: 'app',
badgeColor: '#000',
addedAt: 0,
connectionId: 'openclaw'
} as Repo
]
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'm4air',
remotePath: '/srv/app'
})
expect((result as { alreadyExisted: boolean }).alreadyExisted).toBe(false)
expect((result as { repo: Repo }).repo.executionHostId).toBe('ssh:m4air')
expect(repos).toHaveLength(2)
})
it('does not dedupe onto a local row that carries a stale connection id', async () => {
// The pullfrog case: a row declaring itself local must not answer as an SSH host.
const repos: Repo[] = [
{
id: 'local-row',
path: '/srv/app',
displayName: 'app',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'local',
connectionId: 'develop'
} as Repo
]
const result = await addRemoteRepoFromPath(makeStore(repos) as never, {
connectionId: 'develop',
remotePath: '/srv/app'
})
expect((result as { alreadyExisted: boolean }).alreadyExisted).toBe(false)
expect(repos).toHaveLength(2)
})
})
@@ -1,124 +0,0 @@
import { randomUUID } from 'node:crypto'
import type { Store } from '../../persistence'
import type { Repo } from '../../../shared/repo-types'
import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import { getRepoSshConnectionId, toSshExecutionHostId } from '../../../shared/execution-host'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect'
import { getActiveMultiplexer } from '../../ssh/ssh-target-registry'
import { resolveRemoteHomePath } from './remote-home-path'
export async function addRemoteRepoFromPath(
store: Store,
args: {
connectionId: string
remotePath: string
displayName?: string
kind?: 'git' | 'folder'
setupMethod?: Repo['projectHostSetupMethod']
}
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
}
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath)
// Resolve the host: a row stamped only `executionHostId: 'ssh:*'` is the same registration, and
// missing it here registers a duplicate repo for a path the host already owns.
const existing = store
.getRepos()
.find(
(repo) =>
getRepoSshConnectionId(repo) === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existing) {
return { repo: existing, alreadyExisted: true }
}
if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}
const existingAfterRootResolve = store
.getRepos()
.find(
(repo) =>
getRepoSshConnectionId(repo) === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existingAfterRootResolve) {
return { repo: existingAfterRootResolve, alreadyExisted: true }
}
const folderName = getRemoteRepoFolderName(resolvedPath)
let displayName = args.displayName || folderName
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
const sshTarget = store.getSshTarget(args.connectionId)
if (sshTarget) {
displayName = sshTarget.label
}
}
const detected = await detectRepoIconAndUpstream({
repoPath: resolvedPath,
kind: repoKind,
executionHostId: toSshExecutionHostId(args.connectionId)
})
const repo: Repo = {
id: randomUUID(),
path: resolvedPath,
displayName,
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
// Stamp the unified spelling at creation: this is now the runtime's SSH registration path too,
// and minting `connectionId`-only rows leaves every host-resolving reader on the legacy field.
executionHostId: toSshExecutionHostId(args.connectionId),
...(repoKind === 'git'
? {
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const)
}
: {})
}
store.addRepo(repo)
const mux = getActiveMultiplexer(args.connectionId)
if (mux) {
mux.notify('session.registerRoot', { rootPath: resolvedPath })
}
return { repo, alreadyExisted: false }
}
function getRemoteRepoFolderName(remotePath: string): string {
const trimmed = remotePath.replace(/[\\/]+$/, '')
if (!trimmed) {
return remotePath
}
return trimmed.split(/[\\/]/).at(-1) || remotePath
}
@@ -25,7 +25,6 @@ import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cac
import { emitRepoAdded } from './repo-added-telemetry'
import { notifyReposChanged } from './repos-changed-notification'
import { runWithClonePathLock } from './clone-path-lock'
import { abortActiveRemoteClone, cloneRemoteRepo } from './remote-repo-clone'
type ActiveCloneMetadata = {
path: string
@@ -111,7 +110,6 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor
clone.process.kill()
activeClone = null
}
abortActiveRemoteClone()
})
ipcMain.handle(
@@ -309,16 +307,4 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor
})
}
)
ipcMain.handle(
'repos:cloneRemote',
async (
_event,
args: { connectionId: string; url: string; destination: string }
): Promise<Repo> => {
const repo = await cloneRemoteRepo(store, mainWindow, args)
notifyReposChanged(mainWindow)
return repo
}
)
}
@@ -21,8 +21,6 @@ vi.mock('../registered-worktree-roots-cache', () => ({
vi.mock('./repo-added-telemetry', () => ({ emitRepoAdded: vi.fn() }))
vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() }))
vi.mock('./local-repo-registration', () => ({ addLocalRepoFromPath: vi.fn() }))
vi.mock('./remote-repo-registration', () => ({ addRemoteRepoFromPath: vi.fn() }))
vi.mock('./remote-repo-creation', () => ({ createRemoteRepo: vi.fn() }))
import { probeLocalGitAvailability } from './repo-creation-handlers'
@@ -18,8 +18,6 @@ import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cac
import { emitRepoAdded } from './repo-added-telemetry'
import { notifyReposChanged } from './repos-changed-notification'
import { addLocalRepoFromPath } from './local-repo-registration'
import { addRemoteRepoFromPath } from './remote-repo-registration'
import { createRemoteRepo } from './remote-repo-creation'
const GIT_AVAILABILITY_TIMEOUT_MS = 1500
@@ -83,47 +81,6 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S
}
)
ipcMain.handle(
'repos:addRemote',
async (
_event,
args: {
connectionId: string
remotePath: string
displayName?: string
kind?: 'git' | 'folder'
}
): Promise<{ repo: Repo } | { error: string }> => {
const result = await addRemoteRepoFromPath(store, args)
if ('error' in result) {
return result
}
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
return { repo: result.repo }
}
)
ipcMain.handle(
'repos:createRemote',
async (
_event,
args: {
connectionId: string
parentPath: string
name: string
kind: 'git' | 'folder'
}
): Promise<{ repo: Repo } | { error: string }> => {
const result = await createRemoteRepo(store, args)
if ('error' in result) {
return result
}
notifyReposChanged(mainWindow)
return result
}
)
// Create a repo/folder from scratch (orca#763); git repos need an empty initial commit so HEAD has a branch ref for worktrees.
ipcMain.handle(
'repos:create',
@@ -1,8 +1,7 @@
import { ipcMain } from 'electron'
import type { Store } from '../../persistence'
import { isFolderRepo } from '../../../shared/repo-kind'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { getSshGitUsername, resolveLocalGitUsername } from '../../git/git-username'
import { resolveLocalGitUsername } from '../../git/git-username'
export function registerRepoGitUsernameHandler(store: Store): void {
ipcMain.handle('repos:getGitUsername', async (_event, args: { repoId: string }) => {
@@ -10,13 +9,9 @@ export function registerRepoGitUsernameHandler(store: Store): void {
if (!repo || isFolderRepo(repo)) {
return ''
}
// Why: remote repos keep their git config on the remote host, so resolve the username there.
// Why: an SSH repo's git config lives on a host this process does not serve.
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
return ''
}
return getSshGitUsername(provider, repo.path)
return ''
}
return resolveLocalGitUsername(repo.path)
})
+1 -1
View File
@@ -784,7 +784,7 @@ describe('Store', () => {
it('reassignSshTargetId re-points a repo that only carries connectionId (no executionHostId)', async () => {
const store = await createStore()
// SSH repos created via addRemoteRepoFromPath leave executionHostId unset.
// Legacy SSH repo rows carry only connectionId and leave executionHostId unset.
store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' }))
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
@@ -75,7 +75,7 @@ export function reassignSshTargetId(
if (matchesConnection) {
repo.connectionId = newTargetId
}
// Why: don't stamp executionHostId where it was unset — addRemoteRepoFromPath repos derive the host from connectionId.
// Why: don't stamp executionHostId where it was unset — legacy SSH repo rows derive the host from connectionId.
if (matchesHost) {
repo.executionHostId = newHostId
}
@@ -4,8 +4,7 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { listWorktreeGraph } from '../git/worktree'
import {
createNestedRepoImportTargetResolver,
resolveLocalNestedRepoImportTargetPath,
resolveSshNestedRepoImportTargetPath
resolveLocalNestedRepoImportTargetPath
} from './nested-repo-import-target'
vi.mock('../git/worktree', () => ({
@@ -94,23 +93,4 @@ describe('nested repo import target resolution', () => {
await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath)
})
it('uses the SSH provider worktree list for remote import targets', async () => {
const mainPath = join('/srv', 'source', 'demo')
const selectedPath = join('/srv', 'paseo', 'demo', 'brash-binder')
const gitProvider = {
listWorktrees: vi
.fn()
.mockResolvedValue([
worktree({ path: mainPath, isMainWorktree: true }),
worktree({ path: selectedPath, branch: 'refs/heads/brash-binder' })
])
}
await expect(resolveSshNestedRepoImportTargetPath(selectedPath, gitProvider)).resolves.toBe(
mainPath
)
expect(gitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath)
expect(listWorktreeGraph).not.toHaveBeenCalled()
})
})
@@ -2,13 +2,8 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { listWorktreeGraph } from '../git/worktree'
type WorktreeLister = {
listWorktrees: (repoPath: string) => Promise<GitWorktreeInfo[]>
}
export type NestedRepoImportTargetResolver = {
resolveLocal: (repoPath: string) => Promise<string>
resolveSsh: (repoPath: string, gitProvider: WorktreeLister) => Promise<string>
}
function findImportTarget(
@@ -59,29 +54,12 @@ async function resolveWithCache(
export function createNestedRepoImportTargetResolver(): NestedRepoImportTargetResolver {
const localCache = new Map<string, string>()
const sshCaches = new WeakMap<WorktreeLister, Map<string, string>>()
return {
resolveLocal: (repoPath) =>
resolveWithCache(repoPath, localCache, (path) => listWorktreeGraph(path)),
resolveSsh: (repoPath, gitProvider) => {
let cache = sshCaches.get(gitProvider)
if (!cache) {
cache = new Map()
sshCaches.set(gitProvider, cache)
}
return resolveWithCache(repoPath, cache, (path) => gitProvider.listWorktrees(path))
}
resolveWithCache(repoPath, localCache, (path) => listWorktreeGraph(path))
}
}
export async function resolveLocalNestedRepoImportTargetPath(repoPath: string): Promise<string> {
return createNestedRepoImportTargetResolver().resolveLocal(repoPath)
}
export async function resolveSshNestedRepoImportTargetPath(
repoPath: string,
gitProvider: WorktreeLister
): Promise<string> {
return createNestedRepoImportTargetResolver().resolveSsh(repoPath, gitProvider)
}
@@ -18,3 +18,24 @@ it('retains the path-list fallback for providers without native markdown listing
expect(documents.map((document) => document.relativePath)).toEqual(['a.md', 'b.mdx'])
expect(listFiles).toHaveBeenCalledWith('/repo')
})
it('keeps late Markdown documents from path-list providers with large source inventories', async () => {
const paths = Array.from({ length: 25_002 }, (_, index) => `src/file-${index}.ts`)
paths.push('docs/late.md')
const listFiles = vi.fn(async () => paths)
await expect(listFilesystemMarkdownDocuments({ listFiles }, '/repo')).resolves.toEqual([
{
filePath: '/repo/docs/late.md',
relativePath: 'docs/late.md',
basename: 'late.md',
name: 'late'
}
])
})
it('still bounds path-list inventories before constructing Markdown metadata', async () => {
const listFiles = vi.fn(async () => [`${'x'.repeat(65_537)}.ts`, 'README.md'])
await expect(listFilesystemMarkdownDocuments({ listFiles }, '/repo')).rejects.toThrow(
'File inventory is too large'
)
})
@@ -5,11 +5,20 @@
export const SSH_HOST_NOT_SERVED_MESSAGE =
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
/** The wording project registration on an SSH target has always used. */
export function sshTargetNotConnectedMessage(connectionId: string): string {
return `SSH connection "${connectionId}" not found or not connected`
}
// Why no custom `name`: Electron IPC rejects with `${error}`, so the renderer keeps seeing
// `Error: <message>`, the text these paths showed before the typed error existed.
export class SshHostNotServedError extends Error {
readonly code = 'ssh_host_not_served'
constructor(readonly connectionId: string) {
super(SSH_HOST_NOT_SERVED_MESSAGE)
this.name = 'SshHostNotServedError'
constructor(
readonly connectionId: string,
message: string = SSH_HOST_NOT_SERVED_MESSAGE
) {
super(message)
}
}
@@ -1,24 +0,0 @@
import type { IFilesystemProvider } from './types'
export async function resolveSshQuickOpenDiscoveryOptions(
provider: IFilesystemProvider,
options: {
includeIgnored?: boolean
followSymlinks?: boolean
allowLegacyIncludeIgnored?: boolean
},
signal?: AbortSignal
): Promise<{ includeIgnored?: boolean; followSymlinks?: boolean }> {
const supported =
(options.includeIgnored !== false && !options.followSymlinks) ||
(await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 2 }))
if (!supported && (options.followSymlinks || !options.allowLegacyIncludeIgnored)) {
throw new Error('Update the remote host to use Quick Open listing options.')
}
return {
...(options.includeIgnored === undefined || !supported
? {}
: { includeIgnored: options.includeIgnored }),
...(options.followSymlinks === undefined ? {} : { followSymlinks: options.followSymlinks })
}
}
@@ -1,35 +0,0 @@
import type { getPullRequestDraftContext } from '../text-generation/pull-request-context'
import type { SshGitProvider } from './ssh-git-provider'
type DraftExecOptions = Parameters<Parameters<typeof getPullRequestDraftContext>[0]>[1]
export async function execSshReviewDraft(
provider: Pick<SshGitProvider, 'exec' | 'fetchRemoteTrackingRef' | 'readReviewDiff'>,
args: string[],
cwd: string,
commandOptions?: DraftExecOptions
): Promise<{ stdout: string; stderr: string }> {
const timeoutMs = commandOptions?.timeoutMs ?? commandOptions?.timeout
const options = timeoutMs === undefined ? undefined : { timeoutMs }
if (args.length === 4 && args[0] === 'fetch' && args[1] === '--no-tags') {
const match = /^\+refs\/heads\/([^:]+):(.+)$/.exec(args[3])
if (match && match[2] === `refs/remotes/${args[2]}/${match[1]}`) {
await provider.fetchRemoteTrackingRef(cwd, args[2], match[1], match[2])
return { stdout: '', stderr: '' }
}
}
const range = /^([0-9a-f]{40}(?:[0-9a-f]{24})?)\.\.HEAD$/i.exec(args.at(-1) ?? '')
if (args[0] === 'diff' && range) {
const flags = args.slice(1, -1).join('\0')
const format =
flags === '--name-status'
? 'name-status'
: flags === '--patch\0--minimal\0--no-color\0--no-ext-diff'
? 'patch'
: null
if (format) {
return provider.readReviewDiff(cwd, range[1], format, options)
}
}
return options ? provider.exec(args, cwd, options) : provider.exec(args, cwd)
}
+1 -2
View File
@@ -44,8 +44,7 @@ async function enrichRepoGitUsernamesInBackground(
const candidates = repos.filter(
(repo) =>
repo.kind !== 'folder' &&
// Why: SSH repo paths are remote; local git cannot inspect them. The
// SSH username path (getSshGitUsername) stays caller-driven.
// Why: SSH repo paths are remote; local git cannot inspect them.
!repo.connectionId &&
!attemptedLocations.has(getRepoLocationKey(repo))
)
@@ -31,7 +31,6 @@ import { RuntimeAccountController } from './runtime-account-controller'
import { RuntimeMobileSpeechCatalog } from './runtime-mobile-speech-catalog'
import { RuntimeMobileDictationController } from './runtime-mobile-dictation-controller'
import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller'
import { addRemoteRepoFromPath } from '../ipc/repos/remote-repo-registration'
import { RuntimeProjectGroupController } from './runtime-project-group-controller'
import { RuntimeNestedRepoImport } from './runtime-nested-repo-import'
import { RuntimeRepositoryRegistrationController } from './runtime-repository-registration-controller'
@@ -249,17 +248,6 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin
listRepos: () => this.listRepos(),
addRepo: (path, kind, hostId) =>
(this as RuntimeCommandSurfaceHost<this>).addRepo(path, kind, hostId),
addRemoteRepo: async (remote) => {
// The same registration the desktop IPC handler uses, so both surfaces agree on SSH hosts.
const result = await addRemoteRepoFromPath(this.requireStore(), remote)
if ('error' in result) {
throw new Error(result.error)
}
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(result.repo.id)
this.notifyReposChanged()
return result.repo
},
cloneRepo: (url, destination, hostId) =>
(this as RuntimeCommandSurfaceHost<this>).cloneRepo(url, destination, hostId),
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
@@ -46,7 +46,6 @@ function makeController(options: {
getStore: () => store as never,
listRepos: () => [targetRepo],
addRepo: vi.fn(),
addRemoteRepo: vi.fn(),
cloneRepo: vi.fn(),
invalidateResolvedWorktrees: vi.fn(),
invalidateWorktreeScan: vi.fn(),
@@ -1,45 +1,39 @@
// The CLI/runtime RPC used to refuse `--host ssh:*` with "set the project up from the Orca desktop
// app" — while the desktop IPC handler in the *same process* routed it correctly through
// addRemoteRepoFromPath. Safe but wrong: the process refusing is the one that owns the connection.
import { describe, expect, it, vi } from 'vitest'
import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import type { Repo } from '../../shared/repo-types'
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
const TARGET_ID = 'target-1'
const REMOTE_PATH = '/srv/app'
const REPO_PATH = '/srv/app'
const remoteRepo = {
id: 'repo-remote',
path: REMOTE_PATH,
const repo: Repo = {
id: 'repo-1',
path: REPO_PATH,
displayName: 'app',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
connectionId: TARGET_ID
} as unknown as Repo
kind: 'git'
}
function makeController(): {
controller: RuntimeProjectHostSetupController
addRepo: ReturnType<typeof vi.fn>
addRemoteRepo: ReturnType<typeof vi.fn>
cloneRepo: ReturnType<typeof vi.fn>
projectId: string
} {
const store = {
getProjects: () => projectHostSetupProjectionFromRepos([remoteRepo]).projects,
getProjects: () => projectHostSetupProjectionFromRepos([repo]).projects,
getProjectHostSetups: () => [],
updateRepo: (_id: string, updates: Record<string, unknown>) => ({ ...remoteRepo, ...updates })
updateRepo: (_id: string, updates: Record<string, unknown>) => ({ ...repo, ...updates })
}
const addRepo = vi.fn().mockResolvedValue(remoteRepo)
const addRemoteRepo = vi.fn().mockResolvedValue(remoteRepo)
const cloneRepo = vi.fn().mockResolvedValue(remoteRepo)
const addRepo = vi.fn().mockResolvedValue(repo)
const cloneRepo = vi.fn().mockResolvedValue(repo)
const controller = new RuntimeProjectHostSetupController({
getStore: () => store as never,
listRepos: () => [remoteRepo],
listRepos: () => [repo],
addRepo,
addRemoteRepo,
cloneRepo,
invalidateResolvedWorktrees: vi.fn(),
invalidateWorktreeScan: vi.fn(),
@@ -49,74 +43,61 @@ function makeController(): {
return {
controller,
addRepo,
addRemoteRepo,
cloneRepo,
projectId: getProjectHostSetupForRepo([], remoteRepo).projectId
projectId: getProjectHostSetupForRepo([], repo).projectId
}
}
describe('RuntimeProjectHostSetupController host routing', () => {
it('registers an existing folder on an SSH host instead of refusing it (#11163)', async () => {
const { controller, addRepo, addRemoteRepo, projectId } = makeController()
it('refuses an existing folder on an SSH target with the not-connected text, never locally', async () => {
const { controller, addRepo, projectId } = makeController()
const result = await controller.setupExistingFolder({
const setup = controller.setupExistingFolder({
projectId,
hostId: `ssh:${TARGET_ID}`,
path: REMOTE_PATH,
path: REPO_PATH,
kind: 'git'
})
expect(addRemoteRepo).toHaveBeenCalledWith({
connectionId: TARGET_ID,
remotePath: REMOTE_PATH,
kind: 'git'
})
// The local registration path validates the path against the client filesystem.
await expect(setup).rejects.toThrow(SshHostNotServedError)
await expect(setup).rejects.toThrow(`SSH connection "${TARGET_ID}" not found or not connected`)
expect(addRepo).not.toHaveBeenCalled()
expect(result.repo.id).toBe(remoteRepo.id)
})
it('adds a new project from a folder on an SSH target, never as a local path', async () => {
const { controller, addRepo, addRemoteRepo } = makeController()
it('names the decoded connection id of a percent-encoded SSH target', async () => {
const { controller, addRepo, projectId } = makeController()
const repo = await controller.addSshRepo({ connectionId: TARGET_ID, remotePath: REMOTE_PATH })
expect(addRemoteRepo).toHaveBeenCalledWith({
connectionId: TARGET_ID,
remotePath: REMOTE_PATH,
kind: 'git'
})
await expect(
controller.setupExistingFolder({
projectId,
hostId: 'ssh:my%20host',
path: REPO_PATH,
kind: 'folder'
})
).rejects.toThrow('SSH connection "my host" not found or not connected')
expect(addRepo).not.toHaveBeenCalled()
expect(repo.connectionId).toBe(TARGET_ID)
})
it('decodes a percent-encoded SSH target back to its connection id', async () => {
const { controller, addRemoteRepo, projectId } = makeController()
it('keeps repo.addRemote failing with the not-connected text', () => {
const { controller, addRepo } = makeController()
await controller.setupExistingFolder({
projectId,
hostId: 'ssh:my%20host',
path: REMOTE_PATH,
kind: 'folder'
})
expect(addRemoteRepo).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'my host', kind: 'folder' })
expect(() => controller.addSshRepo({ connectionId: TARGET_ID })).toThrow(
`SSH connection "${TARGET_ID}" not found or not connected`
)
expect(addRepo).not.toHaveBeenCalled()
})
it('still uses the local registration for local and runtime hosts', async () => {
const { controller, addRepo, addRemoteRepo, projectId } = makeController()
const { controller, addRepo, projectId } = makeController()
await controller.setupExistingFolder({
projectId,
hostId: 'local',
path: REMOTE_PATH,
path: REPO_PATH,
kind: 'git'
})
expect(addRepo).toHaveBeenCalledWith(REMOTE_PATH, 'git', 'local')
expect(addRemoteRepo).not.toHaveBeenCalled()
expect(addRepo).toHaveBeenCalledWith(REPO_PATH, 'git', 'local')
})
it('refuses to clone onto an SSH host, because nothing here clones remotely', async () => {
@@ -127,7 +108,7 @@ describe('RuntimeProjectHostSetupController host routing', () => {
projectId,
hostId: `ssh:${TARGET_ID}`,
url: 'https://example.com/app.git',
destination: REMOTE_PATH
destination: REPO_PATH
})
).rejects.toThrow(/Cloning onto an SSH host is not supported/)
expect(cloneRepo).not.toHaveBeenCalled()
@@ -22,6 +22,10 @@ import {
import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import {
SshHostNotServedError,
sshTargetNotConnectedMessage
} from '../providers/ssh-host-not-served-error'
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import type { RuntimeStore } from './runtime-store-contract'
@@ -35,13 +39,6 @@ type RuntimeProjectHostSetupDependencies = {
getStore: () => RuntimeStore | null
listRepos: () => Repo[]
addRepo: (path: string, kind: 'folder' | 'git', hostId: ExecutionHostId) => Promise<Repo>
/** Register an existing path that lives on an SSH host; `addRepo` only reaches local/runtime hosts. */
addRemoteRepo: (args: {
connectionId: string
remotePath: string
displayName?: string
kind: 'folder' | 'git'
}) => Promise<Repo>
cloneRepo: (url: string, destination: string, hostId: ExecutionHostId) => Promise<Repo>
invalidateResolvedWorktrees: () => void
invalidateWorktreeScan: (repoId: string) => void
@@ -49,10 +46,8 @@ type RuntimeProjectHostSetupDependencies = {
countLiveTerminalsForRepo: (repo: Repo) => number
}
// Why clone alone still refuses: nothing in this process clones onto an SSH host. `cloneRepo` runs
// `git clone` on the client, so accepting `ssh:*` here would register the client's copy as the
// host's repo — a local answer to a remote question. Registering an existing remote path, by
// contrast, has a correct implementation this process already uses over IPC.
// Why clone refuses up front: `cloneRepo` runs `git clone` on the client, so accepting `ssh:*`
// here would register the client's copy as the host's repo — a local answer to a remote question.
function assertCloneHostIsSupported(hostId: ExecutionHostId | null | undefined): void {
if (parseExecutionHostId(hostId)?.kind !== 'ssh') {
return
@@ -62,6 +57,10 @@ function assertCloneHostIsSupported(hostId: ExecutionHostId | null | undefined):
)
}
function sshTargetNotServedError(connectionId: string): SshHostNotServedError {
return new SshHostNotServedError(connectionId, sshTargetNotConnectedMessage(connectionId))
}
export class RuntimeProjectHostSetupController {
constructor(private readonly deps: RuntimeProjectHostSetupDependencies) {}
@@ -108,32 +107,20 @@ export class RuntimeProjectHostSetupController {
}
const kind = args.kind === 'folder' ? 'folder' : 'git'
const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id))
// Why route rather than refuse: this process owns the SSH connection, and its own IPC handler
// already registers `ssh:*` hosts correctly. Refusing here only made the CLI and runtime RPC
// disagree with the desktop app about what the same process can do.
const sshTargetId = getSshTargetIdForExecutionHost(args.hostId)
const repo = sshTargetId
? await this.deps.addRemoteRepo({
connectionId: sshTargetId,
remotePath: args.path,
...(args.displayName ? { displayName: args.displayName } : {}),
kind
})
: await this.deps.addRepo(args.path, kind, args.hostId)
if (sshTargetId) {
throw sshTargetNotServedError(sshTargetId)
}
const repo = await this.deps.addRepo(args.path, kind, args.hostId)
return this.completeSetup(args, repo, !knownRepoIds.has(repo.id))
}
/** Registers an existing folder on one of this host's SSH targets as a new project. */
addSshRepo(args: {
connectionId: string
remotePath: string
displayName?: string
kind?: 'folder' | 'git'
}): Promise<Repo> {
/** `repo.addRemote` stays registered for older clients; this process serves no SSH target. */
addSshRepo(args: { connectionId: string }): Promise<Repo> {
if (!this.deps.getStore()) {
throw new Error('runtime_unavailable')
}
return this.deps.addRemoteRepo({ ...args, kind: args.kind === 'folder' ? 'folder' : 'git' })
throw sshTargetNotServedError(args.connectionId)
}
async setupClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
@@ -43,7 +43,6 @@ function makeController(): {
getStore: () => store as unknown as RuntimeStore,
listRepos: () => [repo],
addRepo: vi.fn(),
addRemoteRepo: vi.fn(),
cloneRepo: vi.fn(),
invalidateResolvedWorktrees,
invalidateWorktreeScan: vi.fn(),
@@ -1,19 +1,15 @@
import { isMissingProviderExecutable } from '../provider-process/provider-executable-missing'
import { mergeCommandEnvironment } from '../../shared/command-environment'
import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt'
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
import { getAgentModelProbeSpec } from '../../shared/agent-model-probe-spec'
import type { TuiAgent } from '../../shared/tui-agent'
import { resolveCodexHomeProcessLockKeyForSpawnEnv } from '../codex-cli/codex-home-process-lock'
import { supervisedProviderSpawnFailure } from '../provider-process/provider-spawn-failure-report'
import { isSshRequestOutcomeUnverifiable } from '../ssh/ssh-channel-multiplexer'
import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR } from '../win32-utils'
import {
finalizeModelDiscoveryOutput,
planModelDiscovery,
staticModelDiscoveryResult
} from './commit-message-model-discovery-policy'
import { userFacingUnsafeWindowsBatchArgs } from './source-control-agent-failure'
import {
MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES,
SOURCE_CONTROL_GENERATION_TIMEOUT_MS
@@ -23,7 +19,6 @@ import { killSourceControlAgentProcess } from './source-control-local-process'
import type {
DiscoverCommitMessageModelsResult,
LocalProcessExecution,
RemoteCommitMessageExecResult,
SpawnedSourceControlAgentProcess,
SpawnSourceControlAgent
} from './source-control-text-generation-types'
@@ -209,64 +204,3 @@ export async function discoverModelsLocal(input: {
)
: startDiscovery().result
}
export async function discoverModelsRemote(input: {
agentId: TuiAgent
cwd: string
execute: (
plan: CommitMessagePlan,
cwd: string,
timeoutMs: number
) => Promise<RemoteCommitMessageExecResult>
agentCommandOverride?: string
}): Promise<DiscoverCommitMessageModelsResult> {
const spec = getAgentModelProbeSpec(input.agentId)
if (!spec) {
return { success: false, error: `Agent "${input.agentId}" does not support model discovery.` }
}
if (spec.modelSource === 'static' || !spec.modelDiscovery) {
return staticModelDiscoveryResult(spec)
}
const planned = planModelDiscovery(spec, input.agentCommandOverride)
if (!planned.ok) {
return { success: false, error: planned.error }
}
let result: RemoteCommitMessageExecResult
try {
result = await input.execute(planned.plan, input.cwd, SOURCE_CONTROL_GENERATION_TIMEOUT_MS)
} catch (error) {
console.error('[commit-message] Remote model discovery request failed:', error)
return {
success: false,
error: isSshRequestOutcomeUnverifiable(error)
? `${spec.label} model discovery took longer than ${SOURCE_CONTROL_GENERATION_TIMEOUT_MS / 1000}s and may still be running on the remote host.`
: `${spec.label} model discovery could not be reached on the remote PATH. Try again after the SSH connection recovers.`
}
}
if (result.spawnError) {
if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) {
return { success: false, error: userFacingUnsafeWindowsBatchArgs(spec.label) }
}
if (/ENOENT/i.test(result.spawnError)) {
return {
success: false,
error: `${planned.plan.binary} not found on the remote PATH. Install ${spec.label} there.`
}
}
console.error('[commit-message] Remote model discovery spawn failed:', result.spawnError)
return {
success: false,
error: `${spec.label} model discovery could not be started on the remote PATH. Check the agent command there and try again.`
}
}
if (result.canceled) {
return { success: false, error: 'Model discovery canceled.' }
}
if (result.timedOut) {
return {
success: false,
error: `${spec.label} model discovery timed out after ${SOURCE_CONTROL_GENERATION_TIMEOUT_MS / 1000}s.`
}
}
return finalizeModelDiscoveryOutput(spec, result.stdout, result.stderr, result.exitCode)
}
@@ -1,14 +1,7 @@
import { spawn } from 'node:child_process'
import type * as ChildProcess from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createSshDisposalError,
SSH_MUX_REQUEST_TIMEOUT_CODE
} from '../ssh/ssh-channel-multiplexer'
import {
discoverCommitMessageModelsLocal,
discoverCommitMessageModelsRemote
} from './commit-message-text-generation'
import { discoverCommitMessageModelsLocal } from './commit-message-text-generation'
import { PROVIDER_SPAWN_FAILURE_MARKER } from '../provider-process/provider-spawn-failure-report'
import {
createChildTerminationExpectation,
@@ -528,96 +521,3 @@ describe('discoverCommitMessageModelsLocal on the Windows direct-child path', ()
expect(child.listenerCount('close')).toBe(0)
})
})
describe('generateCommitMessageFromContext on the Windows direct-child path', () => {
it('discovers dynamic models through a remote execution plan', async () => {
const execute = vi.fn(async (plan, cwd, timeoutMs) => {
expect(plan).toEqual({
binary: 'npx',
args: ['cursor-agent', '--list-models'],
stdinPayload: null,
label: 'Cursor'
})
expect(cwd).toBe('/remote/repo')
expect(timeoutMs).toBe(60_000)
return {
stdout: 'auto - Auto\ngpt-5.2 - GPT-5.2\n',
stderr: '',
exitCode: 0,
timedOut: false
}
})
const result = await discoverCommitMessageModelsRemote(
'cursor',
'/remote/repo',
execute,
'npx cursor-agent'
)
expect(result).toMatchObject({
success: true,
defaultModelId: 'auto',
models: [
{ id: 'auto', label: 'Auto' },
{ id: 'gpt-5.2', label: 'GPT-5.2' }
]
})
})
it('reports remote model discovery transport timeouts without PATH guidance', async () => {
const transportTimeout = Object.assign(
new Error('Request "agent.execNonInteractive" timed out after 65000ms'),
{ code: SSH_MUX_REQUEST_TIMEOUT_CODE }
)
const result = await discoverCommitMessageModelsRemote(
'cursor',
'/remote/repo',
async () => {
throw transportTimeout
},
'npx cursor-agent'
)
expect(result).toEqual({
success: false,
error:
'Cursor model discovery took longer than 60s and may still be running on the remote host.'
})
})
it('keeps the unverifiable wording when the link is declared lost instead of timing out', async () => {
// Same regression as the exec leg: a wedged link now disposes the mux before the response
// deadline, so this branch sees CONNECTION_LOST. Reporting "could not be reached" for it
// asserts absence the client never observed (docs/reference/ssh-execution-boundary.md).
const result = await discoverCommitMessageModelsRemote(
'cursor',
'/remote/repo',
async () => {
throw createSshDisposalError('connection_lost')
},
'npx cursor-agent'
)
expect(result).toEqual({
success: false,
error:
'Cursor model discovery took longer than 60s and may still be running on the remote host.'
})
})
it('reports remote model discovery spawn failures with remote install guidance', async () => {
const result = await discoverCommitMessageModelsRemote('cursor', '/remote/repo', async () => ({
stdout: '',
stderr: '',
exitCode: null,
timedOut: false,
spawnError: 'ENOENT'
}))
expect(result).toEqual({
success: false,
error: 'cursor-agent not found on the remote PATH. Install Cursor there.'
})
})
})
@@ -2,7 +2,6 @@ import type { BranchNameWorkContext } from '../../shared/branch-name-from-work'
import type { ConversationNameContext } from '../../shared/conversation-name-generation'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key'
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type {
@@ -18,7 +17,6 @@ import type { AiTextOperation } from '../../shared/source-control-ai-types'
import type { TuiAgent } from '../../shared/tui-agent'
import {
discoverModelsLocal,
discoverModelsRemote,
type CommitMessageModelDiscoveryLocalOptions
} from './commit-message-model-discovery'
import { spawnSourceControlAgent } from './source-control-agent-launch'
@@ -115,19 +113,6 @@ export async function discoverCommitMessageModelsLocal(
})
}
export async function discoverCommitMessageModelsRemote(
agentId: TuiAgent,
cwd: string,
execute: (
plan: CommitMessagePlan,
cwd: string,
timeoutMs: number
) => Promise<RemoteCommitMessageExecResult>,
agentCommandOverride?: string
): Promise<DiscoverCommitMessageModelsResult> {
return discoverModelsRemote({ agentId, cwd, execute, agentCommandOverride })
}
export function cancelGenerateCommitMessageLocal(cwd: string): void {
cancelLocalGeneration('commit-message', cwd)
}
-6
View File
@@ -8,8 +8,6 @@ export const reposApi = {
add: (args) => ipcRenderer.invoke('repos:add', args),
addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args),
create: (args) => ipcRenderer.invoke('repos:create', args),
isGitAvailable: (): Promise<boolean> => ipcRenderer.invoke('repos:isGitAvailable'),
@@ -33,10 +31,6 @@ export const reposApi = {
clone: (args) => ipcRenderer.invoke('repos:clone', args),
cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args),
createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args),
cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'),
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void): (() => void) => {
-14
View File
@@ -72,22 +72,8 @@ export type RepositoryApi = {
pickFolders: () => Promise<string[]>
pickDirectory: () => Promise<string | null>
clone: (args: { url: string; destination: string }) => Promise<Repo>
cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise<Repo>
createRemote: (args: {
connectionId: string
parentPath: string
name: string
kind: 'git' | 'folder'
}) => Promise<{ repo: Repo } | { error: string }>
cloneAbort: () => Promise<void>
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
addRemote: (args: {
connectionId: string
remotePath: string
displayName?: string
kind?: 'git' | 'folder'
}) => Promise<{ repo: Repo } | { error: string }>
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
create: (args: {
parentPath: string
name: string
@@ -15,7 +15,6 @@ const remoteRepo: Repo = {
const reposList = vi.fn()
const reposClone = vi.fn()
const reposCloneRemote = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
let runtimeCapabilities: string[] = []
@@ -43,7 +42,6 @@ beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset()
reposClone.mockReset()
reposCloneRemote.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeCapabilities = []
@@ -57,8 +55,7 @@ beforeEach(() => {
api: {
repos: {
list: reposList,
clone: reposClone,
cloneRemote: reposCloneRemote
clone: reposClone
},
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
@@ -40,7 +40,6 @@ export const reposList: Mock = vi.fn()
export const reposAdd: Mock = vi.fn()
export const reposPickFolder: Mock = vi.fn()
export const reposClone: Mock = vi.fn()
export const reposCloneRemote: Mock = vi.fn()
export const reposRemoveForHost: Mock = vi.fn()
export const reposUpdate: Mock = vi.fn()
export const reposReorder: Mock = vi.fn()
@@ -72,7 +71,6 @@ export function installReposRuntimeRoutingHarness(): void {
reposAdd.mockReset()
reposPickFolder.mockReset()
reposClone.mockReset()
reposCloneRemote.mockReset()
reposRemoveForHost.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
@@ -101,7 +99,6 @@ export function installReposRuntimeRoutingHarness(): void {
list: reposList,
add: reposAdd,
clone: reposClone,
cloneRemote: reposCloneRemote,
pickFolder: reposPickFolder,
removeForHost: reposRemoveForHost,
update: reposUpdate,
@@ -15,7 +15,6 @@ import {
remoteRepo,
reposAdd,
reposClone,
reposCloneRemote,
reposList,
reposPickFolder,
reposRemoveForHost,
@@ -568,7 +567,6 @@ describe('repo slice runtime routing', () => {
})
).resolves.toBeNull()
expect(reposCloneRemote).not.toHaveBeenCalled()
expect(reposClone).not.toHaveBeenCalled()
expect(projectsSetupExistingFolder).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('Failed to add project', {
@@ -8,7 +8,7 @@ import {
withRuntimeRepoMutationOwner,
withRuntimeRepoOwner
} from './web-runtime-calls'
import { assertActiveEnvironment, invalidateRuntimeWorktreeCaches } from './web-runtime-session'
import { invalidateRuntimeWorktreeCaches } from './web-runtime-session'
import { noopUnsubscribe } from './web-storage'
export function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
@@ -53,35 +53,7 @@ export function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
)
return withRuntimeRepoOwner(owned.result.repo, owned.hostId)
},
cloneRemote: async () => {
// Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path.
throw new Error('SSH clone is unavailable in paired web clients.')
},
createRemote: async () => {
// Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC.
throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.')
},
cloneAbort: () => Promise.resolve(),
addRemote: async ({ remotePath, displayName, kind }) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', {
path: remotePath,
kind
})
const result = {
repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId)
}
if (!displayName) {
return result
}
assertActiveEnvironment(owned.environmentId)
return {
repo: await createReposApi().update({
repoId: result.repo.id,
updates: { displayName }
})
}
},
create: async ({ parentPath, name, kind }) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>(
@@ -35,18 +35,6 @@ describe('web file preload API', () => {
).rejects.toThrow('Remote folder download is unavailable in paired web clients.')
})
it('rejects SSH clone requests in paired web clients', async () => {
const { api } = await installApi('Linux')
await expect(
api.repos.cloneRemote({
connectionId: 'ssh-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/workspace'
})
).rejects.toThrow('SSH clone is unavailable in paired web clients.')
})
it('returns false for runtime missing-path errors from fs.pathExists', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
const worktree = {
+1 -1
View File
@@ -26,7 +26,7 @@ export function getGitCloneFailureMessage(
/** Guidance the raw git error omits: where the clone ran, and why nothing could prompt there. */
function appendCloneTransportGuidance(message: string, scrubbedStderr: string): string {
// Re-entrant: remote-repo-clone re-parses a message the relay already built.
// Re-entrant: a message that already carries the note is returned unchanged.
if (message.includes(CLONE_HOST_NOTE)) {
return message
}