diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index 8be4deece45..465aa732c66 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -317,9 +317,9 @@ Needs: Current branch status: partially implemented. Ready project-host setups can be selected from the composer `Run on` menu, and known hosts that still need setup now appear as setup-target rows instead of being invisible. The composer can -import an existing folder for those hosts and can clone onto non-SSH -local/runtime hosts, then switch to the new ready setup. SSH clone and -provisioning actions are still missing. +import an existing folder for those hosts and can clone onto local, runtime, and +SSH hosts, then switch to the new ready setup. Provisioning actions are still +missing. ### 5. Project Setup Flow @@ -337,8 +337,9 @@ Needs: Current branch status: existing-folder setup is partially implemented through API/CLI plumbing and a project settings import form for known local, SSH, and active runtime hosts. Composer clone setup is implemented for local/runtime -hosts by composing the existing clone APIs with existing-folder setup. SSH -clone, provisioning, and bulk setup are not complete. +hosts by composing the existing clone APIs with existing-folder setup; SSH clone +uses the relay git provider and then links the cloned path. Provisioning and +bulk setup are not complete. ### 6. Sidebar Row Model @@ -482,13 +483,13 @@ Already partially landed: set up yet - inline `Run on` import-existing-folder setup for known hosts where the selected project is not set up yet -- inline `Run on` clone setup for not-yet-set-up non-SSH local/runtime hosts +- inline `Run on` clone setup for not-yet-set-up local, runtime, and SSH hosts - repo-backed setup method metadata so imported/cloned setup methods survive compatibility projection and persistence sync Not complete yet: -- SSH clone/provision flows +- SSH clone progress/abort/cleanup parity and provisioning flows - independent project-host setup persistence beyond the repo-backed compatibility records - bulk setup flows and setup for hosts that are not already known to the client diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index 8766bdc22fc..b40f8c13f83 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -238,8 +238,8 @@ Needs: internals while old APIs remain Status: partially implemented. Ready setups and setup-target rows exist in the -composer; inline import exists; inline clone is wired for non-SSH local/runtime -hosts; SSH clone and provisioning remain. +composer; inline import exists; inline clone is wired for local, runtime, and +SSH hosts; provisioning remains. ### 5. Project Setup Flow @@ -255,8 +255,7 @@ Needs: - future cloud provisioning hook Status: existing-folder setup is partially implemented; clone-on-host is wired -for local/runtime from the composer; SSH clone, provisioning, and bulk setup -remain. +for local/runtime/SSH from the composer; provisioning and bulk setup remain. ### 6. Sidebar Row Model @@ -406,13 +405,13 @@ Already partially landed: - setup-target `Run on` rows for known hosts where the selected project is not set up yet - inline import-existing-folder setup from the composer -- inline clone setup from the composer for not-yet-set-up non-SSH - local/runtime hosts +- inline clone setup from the composer for not-yet-set-up local, runtime, and + SSH hosts - repo-backed setup method metadata for imported/cloned project-host setups Still needed: -- finish SSH clone/provision flows +- finish SSH clone progress/abort/cleanup parity and provisioning flows - independent project-host setup persistence beyond the repo-backed compatibility records - bulk setup flows and setup for newly added hosts diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 21b8937144c..bab5b8e6cc8 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -750,23 +750,23 @@ Landed so far: - Added an inline composer action for those not-yet-set-up hosts to import an existing folder on that host. After import, the composer switches to the new ready setup so workspace creation can continue on that host. -- Added an inline composer clone action for not-yet-set-up non-SSH hosts. It - seeds a GitHub HTTPS clone URL when durable project identity is available, - lets the user paste a different URL, clones into the selected parent - directory through the existing local/runtime clone APIs, then links the - resulting checkout as the project's host setup. +- Added an inline composer clone action for not-yet-set-up hosts. It seeds a + GitHub HTTPS clone URL when durable project identity is available, lets the + user paste a different URL, clones into the selected parent directory through + the existing local/runtime clone APIs or SSH relay git provider, then links + the resulting checkout as the project's host setup. - Added repo-backed setup method metadata so imported and cloned setup flows survive persistence/projection sync as `imported-existing-folder` or `cloned` while `Repo` remains the compatibility source of truth. - Added tests for local repos, SSH repos, same-provider multi-host grouping, no-identity same-name non-grouping, selector cache behavior, persistence backfill, repo mutation synchronization, renderer hydration, runtime RPC - routing, setup method persistence, local/runtime clone setup composition, and - GitHub clone URL inference. Sidebar row-builder tests now cover project-first - multi-host grouping and same-name repo separation without project identity. - Workspace target tests cover local-only fallback, focused-host setup - selection, explicit project-plus-host resolution, same-name non-merging, and - unavailable setup reasons. + routing, setup method persistence, local/runtime/SSH clone setup composition, + remote clone IPC, and GitHub clone URL inference. Sidebar row-builder tests + now cover project-first multi-host grouping and same-name repo separation + without project identity. Workspace target tests cover local-only fallback, + focused-host setup selection, explicit project-plus-host resolution, same-name + non-merging, and unavailable setup reasons. Important limitation: @@ -777,10 +777,9 @@ Important limitation: through their repo compatibility record. Settings now expose setup-specific host panes and existing-folder setup, but still use repo compatibility records underneath. -- SSH clone setup is not implemented yet. SSH hosts can import an existing - folder today; true SSH clone should either reuse a remote git-provider exec - path or add a remote clone flow with progress, abort, safe path derivation, - cleanup, and auth parity. +- SSH clone setup is implemented through the relay git provider, but it does + not yet have local-clone parity for progress events, abort, and owned partial + clone cleanup. - Project-host setup records are still regenerated from repo compatibility records. The setup method now persists through that projection, but the final independent setup table is still future work. @@ -788,7 +787,8 @@ Important limitation: Remaining end-to-end work: - broaden setup-on-host flows beyond known local, SSH, and active runtime hosts -- finish SSH clone, provisioning, and bulk setup-on-host flows +- finish SSH clone progress/abort/cleanup parity, provisioning, and bulk + setup-on-host flows - split settings into explicit client, host, project, and project-host setup scopes - validate the default project-first sidebar view in Electron and continue diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts index 8c0c7df7576..579b7e1f3e7 100644 --- a/src/main/git/repo-clone-path.ts +++ b/src/main/git/repo-clone-path.ts @@ -14,6 +14,21 @@ export type ClaimedCloneTarget = { type CloneDirectoryIdentity = Pick +export function deriveCloneRepoNameFromUrl(url: string): string { + // Why: direct callers can supply URLs whose default git clone folder would + // be "." or ".."; rejecting them prevents parent/destination deletion. + const source = url.replace(/\.git\/?$/, '') + const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') + const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) + if (!repoName || repoName === '.' || repoName === '..') { + throw new Error('Invalid repository name derived from URL') + } + if (repoName.includes('/') || repoName.includes('\\')) { + throw new Error('Invalid repository name derived from URL') + } + return repoName +} + export function deriveValidatedClonePath(args: { url: string; destination: string }): string { if ( !args.destination || @@ -23,17 +38,7 @@ export function deriveValidatedClonePath(args: { url: string; destination: strin throw new Error('Clone destination must be an absolute path') } - // Why: direct callers can supply URLs whose default git clone folder would - // be "." or ".."; rejecting them prevents parent/destination deletion. - const source = args.url.replace(/\.git\/?$/, '') - const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') - const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) - if (!repoName || repoName === '.' || repoName === '..') { - throw new Error('Invalid repository name derived from URL') - } - if (repoName.includes('/') || repoName.includes('\\')) { - throw new Error('Invalid repository name derived from URL') - } + const repoName = deriveCloneRepoNameFromUrl(args.url) const clonePath = join(args.destination, repoName) const resolvedDestination = resolve(args.destination) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 7226ebeff15..44de9ae3ceb 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -38,7 +38,16 @@ const { mockGitProvider: { isGitRepo: vi.fn().mockReturnValue(true), isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + getHostPlatform: vi.fn().mockReturnValue({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix', + commandDialect: 'posix', + pathSeparator: '/', + pathDelimiter: ':' + }) }, mockFilesystemProvider: { readDir: vi.fn().mockResolvedValue([]), @@ -809,6 +818,18 @@ describe('repos:addRemote', () => { mockStore.updateRepo.mockReset() mockGitProvider.isGitRepoAsync.mockReset() mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) + mockGitProvider.exec.mockReset() + mockGitProvider.exec.mockResolvedValue({ stdout: '', stderr: '' }) + mockGitProvider.getHostPlatform.mockReset() + mockGitProvider.getHostPlatform.mockReturnValue({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix', + commandDialect: 'posix', + pathSeparator: '/', + pathDelimiter: ':' + }) mockMultiplexer.request.mockReset() mockMultiplexer.notify.mockReset() gitSpawnMock.mockReset() @@ -827,6 +848,10 @@ describe('repos:addRemote', () => { expect(handlers.has('repos:addRemote')).toBe(true) }) + it('registers the repos:cloneRemote handler', () => { + expect(handlers.has('repos:cloneRemote')).toBe(true) + }) + it('creates a remote repo with connectionId', async () => { const result = await handlers.get('repos:addRemote')!(null, { connectionId: 'conn-1', @@ -865,6 +890,99 @@ describe('repos:addRemote', () => { 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(mockGitProvider.exec).toHaveBeenCalledWith( + ['clone', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/user' + ) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/orca', + connectionId: 'conn-1', + kind: 'git', + displayName: 'orca', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: false + }) + ) + expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/home/user/orca' + }) + expect(result).toHaveProperty('path', '/home/user/orca') + expect(result).toHaveProperty('connectionId', 'conn-1') + }) + + it('returns an existing SSH repo instead of cloning the same target again', 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]) + + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(result).toBe(existing) + expect(mockGitProvider.exec).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + 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.exec).toHaveBeenCalledWith( + ['clone', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/user' + ) + expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', { kind: 'git' }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toBe(updated) + }) + + 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.exec).not.toHaveBeenCalled() + }) + it('returns existing repo if same connectionId and path already added', async () => { const existing = { id: 'existing-id', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 6214e9cd679..f457ed180ee 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -22,15 +22,20 @@ import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../shared/repo-icon' import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai' +import { + isRuntimePathAbsolute, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' import { access, mkdir, readdir, rm } from 'fs/promises' import { gitExecFileAsync, gitSpawn } from '../git/runner' import { isAbsolute, join, posix } from 'path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { cleanupClaimedCloneTarget, claimCloneTarget, + deriveCloneRepoNameFromUrl, deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' @@ -65,6 +70,7 @@ import type { RepoMethod } from '../../shared/telemetry-events' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection' import { parseExecutionHostId } from '../../shared/execution-host' +import { joinRemotePath } from '../ssh/ssh-remote-platform' // Why: `method` answers "which entry point did the user take?", not "what did // they add?" — so the IPC the renderer invoked IS the method. We never send @@ -277,6 +283,68 @@ function getRemoteRepoFolderName(remotePath: string): string { return trimmed.split(/[\\/]/).at(-1) || remotePath } +async function cloneRemoteRepo( + store: Store, + args: { + connectionId: string + url: string + destination: string + } +): Promise { + const gitProvider = getSshGitProvider(args.connectionId) + if (!gitProvider) { + throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) + } + const host = gitProvider.getHostPlatform?.() + if (!host) { + throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.') + } + const trimmedDestination = 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 existing = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === clonePathKey + ) + }) + if (existing && !isFolderRepo(existing)) { + emitRepoAdded('clone_url', true) + return existing + } + + // Why: the SSH relay exposes argv-based git execution, not a shell. Use the + // repo folder name as the target so git creates it inside the chosen parent. + await gitProvider.exec(['clone', '--', args.url.trim(), repoName], trimmedDestination) + if (existing && isFolderRepo(existing)) { + const updated = store.updateRepo(existing.id, { kind: 'git' }) + if (updated) { + emitRepoAdded('clone_url', false) + getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { + rootPath: clonePath + }) + return updated + } + } + const result = await addRemoteRepoFromPath(store, { + connectionId: args.connectionId, + remotePath: clonePath, + kind: 'git' + }) + if ('error' in result) { + throw new Error(result.error) + } + emitRepoAdded('clone_url', result.alreadyExisted) + return result.repo +} + type ActiveCloneMetadata = { path: string pathKey: string @@ -642,6 +710,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:pickDirectory') ipcMain.removeHandler('repos:clone') ipcMain.removeHandler('repos:cloneAbort') + ipcMain.removeHandler('repos:cloneRemote') ipcMain.removeHandler('repos:getGitUsername') ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') @@ -1559,6 +1628,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + ipcMain.handle( + 'repos:cloneRemote', + async ( + _event, + args: { connectionId: string; url: string; destination: string } + ): Promise => { + const repo = await cloneRemoteRepo(store, args) + notifyReposChanged(mainWindow) + return repo + } + ) + ipcMain.handle('repos:getGitUsername', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 1fc9c25c1fb..aa813cb7ba8 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -21,6 +21,7 @@ import { JsonRpcErrorCode } from '../ssh/relay-protocol' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' type NonInteractiveExecQueueEntry = { started: boolean @@ -56,7 +57,11 @@ export class SshGitProvider implements IGitProvider { private nonInteractiveExecQueues = new Map() private loggedWorktreeIsCleanFallback = false - constructor(connectionId: string, mux: SshChannelMultiplexer) { + constructor( + connectionId: string, + mux: SshChannelMultiplexer, + private readonly hostPlatform: RemoteHostPlatform | null = null + ) { this.connectionId = connectionId this.mux = mux } @@ -65,6 +70,10 @@ export class SshGitProvider implements IGitProvider { return this.connectionId } + getHostPlatform(): RemoteHostPlatform | null { + return this.hostPlatform + } + async getStatus( worktreePath: string, options?: { includeIgnored?: boolean } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index deeb8270067..4158761d429 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -529,7 +529,11 @@ export class SshRelaySession { ) registerSshFilesystemProvider(this.targetId, fsProvider) - const gitProvider = new SshGitProvider(this.targetId, mux) + const gitProvider = new SshGitProvider( + this.targetId, + mux, + this.remoteCliBridgeEnv?.hostPlatform ?? null + ) registerSshGitProvider(this.targetId, gitProvider) this.wireUpPtyEvents(ptyProvider) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5bf9c47c606..b3cdfc2fc37 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -751,6 +751,7 @@ export type PreloadApi = { pickFolder: () => Promise pickDirectory: () => Promise clone: (args: { url: string; destination: string }) => Promise + cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise cloneAbort: () => Promise // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. addRemote: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 4902918c858..c50055712f2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -468,6 +468,8 @@ const api = { clone: (args) => ipcRenderer.invoke('repos:clone', args), + cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), + cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), onCloneProgress: ( diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 082ccb15e9d..c9d728083e7 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -456,8 +456,7 @@ export default function NewWorkspaceComposerCard({ setPendingSetupImporting(false) } }, [onProjectHostExistingFolderSetup, pendingSetupHostOption, pendingSetupKind, pendingSetupPath]) - const canClonePendingSetup = - !!pendingSetupHostOption && !pendingSetupHostOption.hostId.startsWith('ssh:') + const canClonePendingSetup = !!pendingSetupHostOption const handleCloneProjectHost = React.useCallback(async (): Promise => { if (!pendingSetupHostOption || !pendingCloneUrl.trim() || !pendingCloneDestination.trim()) { return diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 0362a4e71cd..66a452bc98c 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -37,6 +37,7 @@ const reposList = vi.fn() const reposAdd = vi.fn() const reposPickFolder = vi.fn() const reposClone = vi.fn() +const reposCloneRemote = vi.fn() const reposRemove = vi.fn() const reposUpdate = vi.fn() const reposReorder = vi.fn() @@ -52,6 +53,7 @@ beforeEach(() => { reposAdd.mockReset() reposPickFolder.mockReset() reposClone.mockReset() + reposCloneRemote.mockReset() reposRemove.mockReset() reposUpdate.mockReset() reposReorder.mockReset() @@ -69,6 +71,7 @@ beforeEach(() => { list: reposList, add: reposAdd, clone: reposClone, + cloneRemote: reposCloneRemote, pickFolder: reposPickFolder, remove: reposRemove, update: reposUpdate, @@ -542,6 +545,61 @@ describe('repo slice runtime routing', () => { }) }) + it('clones a project on an SSH host before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['ssh-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...sshRepo, path: '/srv/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'ssh:ssh-1', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } + reposCloneRemote.mockResolvedValue(clonedRepo) + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'ssh:ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...clonedRepo, executionHostId: 'ssh:ssh-1' } + }) + + expect(reposCloneRemote).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'ssh:ssh-1', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project', + setupMethod: 'cloned' + }) + }) + it('keeps runtime ownership when a runtime repo is moved between groups', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-move', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index fbd3b047235..1c4ba79d882 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -719,29 +719,30 @@ export const createRepoSlice: StateCreator = (set, setupProjectClone: async (args) => { try { const parsedHost = parseExecutionHostId(args.hostId) - if (parsedHost?.kind === 'ssh') { - throw new Error( - 'Clone setup is not available for SSH hosts yet. Import an existing folder on that host instead.' - ) - } const target = getProjectSetupRuntimeTarget(args.hostId) const repo = - target.kind === 'local' - ? await window.api.repos.clone({ + parsedHost?.kind === 'ssh' + ? await window.api.repos.cloneRemote({ + connectionId: parsedHost.targetId, url: args.url, destination: args.destination }) - : ( - await callRuntimeRpc<{ repo: Repo }>( - target, - 'repo.clone', - { - url: args.url, - destination: args.destination - }, - { timeoutMs: 10 * 60_000 } - ) - ).repo + : target.kind === 'local' + ? await window.api.repos.clone({ + url: args.url, + destination: args.destination + }) + : ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { + url: args.url, + destination: args.destination + }, + { timeoutMs: 10 * 60_000 } + ) + ).repo return await get().setupProjectExistingFolder({ projectId: args.projectId, hostId: args.hostId,