diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bbcedfea963..3b735730964 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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": [ diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index d1b6687756d..a40748fb10c 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index caaf4e4b4f9..09de7f19587 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -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 { diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index 11ca33790ce..d2369b22836 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -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() 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() function getLocalGitExecutionHostKey(target: LocalGitCapabilityTarget): string { const wslDistro = @@ -66,16 +62,6 @@ export function withLocalGitCapabilityCacheForExecution( ) } -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() } diff --git a/src/main/git/git-username.ts b/src/main/git/git-username.ts index e69d46d25f9..03b2a69d2ff 100644 --- a/src/main/git/git-username.ts +++ b/src/main/git/git-username.ts @@ -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 { - // 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 } diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index f1bb563be62..a24490c592f 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -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 } diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index c37f33bc091..159efb3ade7 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -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() - }) }) diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index a4c4a17d7f8..4cda90638dc 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -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 - 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() }) }) diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index f8e79bc559c..5948c89ec94 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -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']) diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index cecc7c089f7..8bec934ff8b 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -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() } diff --git a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts index 04c697d08dc..5a192a41637 100644 --- a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts @@ -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((_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() + } + ) }) diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index 7430465ecbd..5eb44a36719 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -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') - }) }) diff --git a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts index 6d62844dc0a..5fedebc34d2 100644 --- a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts +++ b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts @@ -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) }) diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index dd76daf2959..8e2cf2b5a25 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -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 } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 21dc691cbcc..70fe879323b 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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((_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 - - 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() - } - ) }) diff --git a/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts b/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts index e04ccec5e78..36428096a03 100644 --- a/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts +++ b/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts @@ -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) diff --git a/src/main/ipc/filesystem/filesystem-content-search-handler.ts b/src/main/ipc/filesystem/filesystem-content-search-handler.ts index 0ed7ad4d26e..bf1bf044236 100644 --- a/src/main/ipc/filesystem/filesystem-content-search-handler.ts +++ b/src/main/ipc/filesystem/filesystem-content-search-handler.ts @@ -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), diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index b542b235a48..d8f5140e43d 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -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 => { - const filePath = validateRequiredString(args?.filePath, 'filePath') + async (_event, args: { filePath?: string; connectionId?: string }): Promise => { + 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) } ) diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts index ae029dc2f41..448ea9f9f5b 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -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 => { 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) diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts index 8de07853057..2cf4215ec90 100644 --- a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -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[3] diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts index 6cefc50f3eb..07afcce01e0 100644 --- a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -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> - 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 => { 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) diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 1c4b2cadd3c..cc34f6f6f16 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -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 => { 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 => { 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 => { 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 => { 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) diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index f7233757a7c..f6c18e30b87 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -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 => { 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 [] diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index eaa3f245dd9..6a9e5dcd940 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -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 { - 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 diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 6aa3502adb7..ee9646ac3e6 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -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, { diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 74b9626574d..e3b584977d2 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -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' diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index 834c9e97e92..b3cf63793bb 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -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', diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index 00284d42699..e79be629d90 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -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' diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index 8bb62d42fbc..de92762f264 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -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 + ): Promise { + 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' }] }) + }) }) }) diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index 53d4e64477f..b1088832bcd 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -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 () => { diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 8f5ff7dba53..08c5e1c042b 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -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 - } - const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise) => { - 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 `**` 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 /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 () => { diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index 41d254fc16e..aa07aa5527d 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -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 = { - '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() }) }) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index eea5e767725..df6dcfa16d4 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -29,15 +29,8 @@ export type ReposIpcMocks = { | 'getSshTargets', ReposIpcSpy > & { updateRepo: Mock<(repoId: string, updates: Record) => 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 { const isGitRepo = vi.fn().mockReturnValue(true) const getGitRepoRoot = vi.fn((path: string) => path) @@ -188,17 +160,6 @@ export function sshFilesystemDispatchModuleMock(mocks: ReposIpcMocks): Record { - return { - getActiveMultiplexer: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1') { - return mocks.mockMultiplexer - } - return undefined - }) - } -} - export type RepoHandlerHarness = { handlers: Map 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) } diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts deleted file mode 100644 index 95f6878a251..00000000000 --- a/src/main/ipc/repos-remote.test.ts +++ /dev/null @@ -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()) -) -vi.mock('../git/runner', async (importOriginal) => - moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) -) -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') - }) -}) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 3c12365aa0d..1c2b5d8473a 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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') diff --git a/src/main/ipc/repos/base-ref-query-handlers.ts b/src/main/ipc/repos/base-ref-query-handlers.ts index f638e7ce849..6cff029445c 100644 --- a/src/main/ipc/repos/base-ref-query-handlers.ts +++ b/src/main/ipc/repos/base-ref-query-handlers.ts @@ -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 => { - 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 => { - 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 => { - 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) } diff --git a/src/main/ipc/repos/nested-repo-import-handler.ts b/src/main/ipc/repos/nested-repo-import-handler.ts index 6d1fceb9391..69fa84a4723 100644 --- a/src/main/ipc/repos/nested-repo-import-handler.ts +++ b/src/main/ipc/repos/nested-repo-import-handler.ts @@ -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. diff --git a/src/main/ipc/repos/nested-repo-scan-ipc.ts b/src/main/ipc/repos/nested-repo-scan-ipc.ts index 1c6fe8fa7ed..579c10e11d6 100644 --- a/src/main/ipc/repos/nested-repo-scan-ipc.ts +++ b/src/main/ipc/repos/nested-repo-scan-ipc.ts @@ -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() @@ -72,23 +69,6 @@ export function getCompletedNestedRepoScan(args: { return completed.scan } -async function resolveSshProjectGroupPath(connectionId: string, path: string): Promise { - 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( diff --git a/src/main/ipc/repos/project-host-setup-handlers.ts b/src/main/ipc/repos/project-host-setup-handlers.ts index 8898d5ec07a..79d7976ecec 100644 --- a/src/main/ipc/repos/project-host-setup-handlers.ts +++ b/src/main/ipc/repos/project-host-setup-handlers.ts @@ -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.' diff --git a/src/main/ipc/repos/remote-home-path.ts b/src/main/ipc/repos/remote-home-path.ts deleted file mode 100644 index 602eed25684..00000000000 --- a/src/main/ipc/repos/remote-home-path.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { getActiveMultiplexer } from '../../ssh/ssh-target-registry' - -export async function resolveRemoteHomePath(connectionId: string, path: string): Promise { - 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 - } -} diff --git a/src/main/ipc/repos/remote-repo-clone.ts b/src/main/ipc/repos/remote-repo-clone.ts deleted file mode 100644 index 8234a65d277..00000000000 --- a/src/main/ipc/repos/remote-repo-clone.ts +++ /dev/null @@ -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() - -export async function cloneRemoteRepo( - store: Store, - mainWindow: BrowserWindow, - args: { - connectionId: string - url: string - destination: string - } -): Promise { - 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 - } -} diff --git a/src/main/ipc/repos/remote-repo-creation.ts b/src/main/ipc/repos/remote-repo-creation.ts deleted file mode 100644 index d7a2315fb1e..00000000000 --- a/src/main/ipc/repos/remote-repo-creation.ts +++ /dev/null @@ -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 } -} diff --git a/src/main/ipc/repos/remote-repo-registration.test.ts b/src/main/ipc/repos/remote-repo-registration.test.ts deleted file mode 100644 index 0a7069b1f63..00000000000 --- a/src/main/ipc/repos/remote-repo-registration.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/ipc/repos/remote-repo-registration.ts b/src/main/ipc/repos/remote-repo-registration.ts deleted file mode 100644 index ff92f7d0b1b..00000000000 --- a/src/main/ipc/repos/remote-repo-registration.ts +++ /dev/null @@ -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 -} diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index bbc8c96f7f3..595122e60cb 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -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 => { - const repo = await cloneRemoteRepo(store, mainWindow, args) - notifyReposChanged(mainWindow) - return repo - } - ) } diff --git a/src/main/ipc/repos/repo-creation-git-availability.test.ts b/src/main/ipc/repos/repo-creation-git-availability.test.ts index fb112952470..503da0945f5 100644 --- a/src/main/ipc/repos/repo-creation-git-availability.test.ts +++ b/src/main/ipc/repos/repo-creation-git-availability.test.ts @@ -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' diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index 57bfcf66c79..9c98256697c 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -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', diff --git a/src/main/ipc/repos/repo-git-username-handler.ts b/src/main/ipc/repos/repo-git-username-handler.ts index f96b6e52804..c384489ac70 100644 --- a/src/main/ipc/repos/repo-git-username-handler.ts +++ b/src/main/ipc/repos/repo-git-username-handler.ts @@ -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) }) diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index fdfe9f7a699..e954cd05c20 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -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') diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index c996dc445f2..8099496fabf 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -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 } diff --git a/src/main/project-groups/nested-repo-import-target.test.ts b/src/main/project-groups/nested-repo-import-target.test.ts index c630e102fb3..865464e853d 100644 --- a/src/main/project-groups/nested-repo-import-target.test.ts +++ b/src/main/project-groups/nested-repo-import-target.test.ts @@ -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() - }) }) diff --git a/src/main/project-groups/nested-repo-import-target.ts b/src/main/project-groups/nested-repo-import-target.ts index be722716856..6dbc7f782c0 100644 --- a/src/main/project-groups/nested-repo-import-target.ts +++ b/src/main/project-groups/nested-repo-import-target.ts @@ -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 -} - export type NestedRepoImportTargetResolver = { resolveLocal: (repoPath: string) => Promise - resolveSsh: (repoPath: string, gitProvider: WorktreeLister) => Promise } function findImportTarget( @@ -59,29 +54,12 @@ async function resolveWithCache( export function createNestedRepoImportTargetResolver(): NestedRepoImportTargetResolver { const localCache = new Map() - const sshCaches = new WeakMap>() - 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 { return createNestedRepoImportTargetResolver().resolveLocal(repoPath) } - -export async function resolveSshNestedRepoImportTargetPath( - repoPath: string, - gitProvider: WorktreeLister -): Promise { - return createNestedRepoImportTargetResolver().resolveSsh(repoPath, gitProvider) -} diff --git a/src/main/providers/filesystem-markdown-listing.test.ts b/src/main/providers/filesystem-markdown-listing.test.ts index e010ab9cc95..abdce6de723 100644 --- a/src/main/providers/filesystem-markdown-listing.test.ts +++ b/src/main/providers/filesystem-markdown-listing.test.ts @@ -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' + ) +}) diff --git a/src/main/providers/ssh-host-not-served-error.ts b/src/main/providers/ssh-host-not-served-error.ts index 5830556b306..b83e7ae3daf 100644 --- a/src/main/providers/ssh-host-not-served-error.ts +++ b/src/main/providers/ssh-host-not-served-error.ts @@ -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: `, 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) } } diff --git a/src/main/providers/ssh-quick-open-discovery-options.ts b/src/main/providers/ssh-quick-open-discovery-options.ts deleted file mode 100644 index c0ac6652eac..00000000000 --- a/src/main/providers/ssh-quick-open-discovery-options.ts +++ /dev/null @@ -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 }) - } -} diff --git a/src/main/providers/ssh-review-draft-context.ts b/src/main/providers/ssh-review-draft-context.ts deleted file mode 100644 index b3d7a913ab0..00000000000 --- a/src/main/providers/ssh-review-draft-context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import type { SshGitProvider } from './ssh-git-provider' - -type DraftExecOptions = Parameters[0]>[1] - -export async function execSshReviewDraft( - provider: Pick, - 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) -} diff --git a/src/main/repo-git-username-enrichment.ts b/src/main/repo-git-username-enrichment.ts index aa9bb7dd415..74c82e1c674 100644 --- a/src/main/repo-git-username-enrichment.ts +++ b/src/main/repo-git-username-enrichment.ts @@ -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)) ) diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 43454af902a..167fdb2c440 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -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).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).cloneRepo(url, destination, hostId), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), diff --git a/src/main/runtime/project-host-setup-delete-guard.test.ts b/src/main/runtime/project-host-setup-delete-guard.test.ts index 37c2e5d7368..fcb6e42eebe 100644 --- a/src/main/runtime/project-host-setup-delete-guard.test.ts +++ b/src/main/runtime/project-host-setup-delete-guard.test.ts @@ -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(), diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index 114f0069904..3349ff52097 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -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 - addRemoteRepo: ReturnType cloneRepo: ReturnType projectId: string } { const store = { - getProjects: () => projectHostSetupProjectionFromRepos([remoteRepo]).projects, + getProjects: () => projectHostSetupProjectionFromRepos([repo]).projects, getProjectHostSetups: () => [], - updateRepo: (_id: string, updates: Record) => ({ ...remoteRepo, ...updates }) + updateRepo: (_id: string, updates: Record) => ({ ...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() diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index fcd7ba286fa..a29e999d1a2 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -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 - /** 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 cloneRepo: (url: string, destination: string, hostId: ExecutionHostId) => Promise 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.addRemote` stays registered for older clients; this process serves no SSH target. */ + addSshRepo(args: { connectionId: string }): Promise { 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 { diff --git a/src/main/runtime/runtime-project-host-setup-publication.test.ts b/src/main/runtime/runtime-project-host-setup-publication.test.ts index ad39e2a51d0..3592f17d5b4 100644 --- a/src/main/runtime/runtime-project-host-setup-publication.test.ts +++ b/src/main/runtime/runtime-project-host-setup-publication.test.ts @@ -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(), diff --git a/src/main/text-generation/commit-message-model-discovery.ts b/src/main/text-generation/commit-message-model-discovery.ts index 32a27e51d88..c327541c106 100644 --- a/src/main/text-generation/commit-message-model-discovery.ts +++ b/src/main/text-generation/commit-message-model-discovery.ts @@ -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 - agentCommandOverride?: string -}): Promise { - 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) -} diff --git a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts index aae4fa8e13b..15844fdd3fe 100644 --- a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts +++ b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts @@ -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.' - }) - }) -}) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 9e4a28de8e8..05e611b97d6 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -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, - agentCommandOverride?: string -): Promise { - return discoverModelsRemote({ agentId, cwd, execute, agentCommandOverride }) -} - export function cancelGenerateCommitMessageLocal(cwd: string): void { cancelLocalGeneration('commit-message', cwd) } diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index 2357912c640..ddbe22a1cc0 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -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 => 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) => { diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 2efa64fcbf6..e2a29543dca 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -72,22 +72,8 @@ export type RepositoryApi = { pickFolders: () => Promise pickDirectory: () => Promise clone: (args: { url: string; destination: string }) => Promise - cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise - createRemote: (args: { - connectionId: string - parentPath: string - name: string - kind: 'git' | 'folder' - }) => Promise<{ repo: Repo } | { error: string }> cloneAbort: () => Promise // 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 diff --git a/src/renderer/src/store/slices/repos-project-host-capability.test.ts b/src/renderer/src/store/slices/repos-project-host-capability.test.ts index c8689034cde..f65ba3b50fd 100644 --- a/src/renderer/src/store/slices/repos-project-host-capability.test.ts +++ b/src/renderer/src/store/slices/repos-project-host-capability.test.ts @@ -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 } } diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts index b61e956b2d0..d2fb6e6bb3c 100644 --- a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -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, diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index cd3977fffc8..d5b2ef6b1f7 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -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', { diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts index 1f2ad6e2d13..e21c440bd4f 100644 --- a/src/renderer/src/web/preload-api/web-repositories-api.ts +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -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['repos']> { @@ -53,35 +53,7 @@ export function createReposApi(): NonNullable['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 }>( diff --git a/src/renderer/src/web/web-preload-api-filesystem.test.ts b/src/renderer/src/web/web-preload-api-filesystem.test.ts index c2e9bf38b75..6e54b7fef35 100644 --- a/src/renderer/src/web/web-preload-api-filesystem.test.ts +++ b/src/renderer/src/web/web-preload-api-filesystem.test.ts @@ -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 = { diff --git a/src/shared/git-clone-failure-message.ts b/src/shared/git-clone-failure-message.ts index e706d523e5c..5cb8b956d1f 100644 --- a/src/shared/git-clone-failure-message.ts +++ b/src/shared/git-clone-failure-message.ts @@ -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 }