mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,21 @@ export type ClaimedCloneTarget = {
|
||||
|
||||
type CloneDirectoryIdentity = Pick<Stats, 'dev' | 'ino' | 'birthtimeMs'>
|
||||
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
+82
-1
@@ -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<Repo> {
|
||||
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<Repo> => {
|
||||
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)) {
|
||||
|
||||
@@ -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<string, NonInteractiveExecQueueEntry[]>()
|
||||
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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -751,6 +751,7 @@ export type PreloadApi = {
|
||||
pickFolder: () => Promise<string | null>
|
||||
pickDirectory: () => Promise<string | null>
|
||||
clone: (args: { url: string; destination: string }) => Promise<Repo>
|
||||
cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise<Repo>
|
||||
cloneAbort: () => Promise<void>
|
||||
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
|
||||
addRemote: (args: {
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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<void> => {
|
||||
if (!pendingSetupHostOption || !pendingCloneUrl.trim() || !pendingCloneDestination.trim()) {
|
||||
return
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -719,29 +719,30 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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,
|
||||
|
||||
Reference in New Issue
Block a user