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