mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Stream SSH clone progress to renderer
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -39,6 +39,7 @@ const {
|
||||
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: '' }),
|
||||
getHostPlatform: vi.fn().mockReturnValue({
|
||||
relayPlatform: 'linux-x64',
|
||||
os: 'linux',
|
||||
@@ -821,6 +822,8 @@ describe('repos:addRemote', () => {
|
||||
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',
|
||||
@@ -902,12 +905,13 @@ describe('repos:addRemote', () => {
|
||||
destination: '/home/user'
|
||||
})
|
||||
|
||||
expect(mockGitProvider.exec).toHaveBeenCalledWith(
|
||||
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
|
||||
timeoutMs: 10 * 60_000,
|
||||
onProgress: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
@@ -928,6 +932,30 @@ describe('repos:addRemote', () => {
|
||||
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('returns an existing SSH repo instead of cloning the same target again', async () => {
|
||||
const existing = {
|
||||
id: 'existing-id',
|
||||
@@ -947,7 +975,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
|
||||
expect(result).toBe(existing)
|
||||
expect(mockGitProvider.exec).not.toHaveBeenCalled()
|
||||
expect(mockGitProvider.clone).not.toHaveBeenCalled()
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -971,12 +999,13 @@ describe('repos:addRemote', () => {
|
||||
destination: '/home/user'
|
||||
})
|
||||
|
||||
expect(mockGitProvider.exec).toHaveBeenCalledWith(
|
||||
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
|
||||
timeoutMs: 10 * 60_000,
|
||||
onProgress: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', { kind: 'git' })
|
||||
@@ -985,7 +1014,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
|
||||
it('cleans up a fresh SSH clone target after git clone fails', async () => {
|
||||
mockGitProvider.exec.mockRejectedValueOnce(new Error('repository not found'))
|
||||
mockGitProvider.clone.mockRejectedValueOnce(new Error('repository not found'))
|
||||
mockFilesystemProvider.stat.mockRejectedValueOnce(new Error('not found'))
|
||||
|
||||
await expect(
|
||||
@@ -1000,7 +1029,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
|
||||
it('does not clean up a pre-existing SSH clone target after git clone fails', async () => {
|
||||
mockGitProvider.exec.mockRejectedValueOnce(new Error('destination already exists'))
|
||||
mockGitProvider.clone.mockRejectedValueOnce(new Error('destination already exists'))
|
||||
mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 })
|
||||
|
||||
await expect(
|
||||
@@ -1016,7 +1045,7 @@ describe('repos:addRemote', () => {
|
||||
|
||||
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.exec.mockImplementationOnce(
|
||||
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')))
|
||||
@@ -1028,12 +1057,12 @@ describe('repos:addRemote', () => {
|
||||
url: 'https://github.com/stablyai/orca.git',
|
||||
destination: '/home/user'
|
||||
})
|
||||
await waitForAssertion(() => expect(mockGitProvider.exec).toHaveBeenCalledTimes(1))
|
||||
await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
|
||||
await expect(clonePromise).rejects.toThrow('Clone aborted')
|
||||
const options = mockGitProvider.exec.mock.calls[0][2] as { signal: AbortSignal }
|
||||
const options = mockGitProvider.clone.mock.calls[0][2] as { signal: AbortSignal }
|
||||
expect(options.signal.aborted).toBe(true)
|
||||
expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1047,7 +1076,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
).rejects.toThrow('Clone destination must be an absolute path on the SSH host')
|
||||
|
||||
expect(mockGitProvider.exec).not.toHaveBeenCalled()
|
||||
expect(mockGitProvider.clone).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns existing repo if same connectionId and path already added', async () => {
|
||||
|
||||
+24
-16
@@ -291,6 +291,7 @@ function getRemoteRepoFolderName(remotePath: string): string {
|
||||
|
||||
async function cloneRemoteRepo(
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
args: {
|
||||
connectionId: string
|
||||
url: string
|
||||
@@ -345,12 +346,17 @@ async function cloneRemoteRepo(
|
||||
// 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, and keep the same flag separator safety as local clone.
|
||||
await gitProvider.exec(
|
||||
await gitProvider.clone(
|
||||
['clone', '--progress', '--', args.url.trim(), repoName],
|
||||
trimmedDestination,
|
||||
{
|
||||
signal: controller.signal,
|
||||
timeoutMs: 10 * 60_000
|
||||
timeoutMs: 10 * 60_000,
|
||||
onProgress: (progress) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:clone-progress', progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -423,6 +429,18 @@ type CompletedNestedRepoScan = {
|
||||
const completedNestedRepoScans = new Map<string, CompletedNestedRepoScan>()
|
||||
const MAX_COMPLETED_NESTED_SCAN_RESULTS = 50
|
||||
|
||||
function emitCloneProgressFromText(mainWindow: BrowserWindow, text: string): void {
|
||||
for (const line of text.split(/[\r\n]+/)) {
|
||||
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
|
||||
if (match && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:clone-progress', {
|
||||
phase: match[1].trim(),
|
||||
percent: parseInt(match[2], 10)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ProjectGroupCreateArgs = z.object({
|
||||
name: z.string().min(1),
|
||||
parentPath: z.string().nullable().optional(),
|
||||
@@ -1662,19 +1680,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
const text = chunk.toString()
|
||||
stderrTail = (stderrTail + text).slice(-4096)
|
||||
|
||||
// Why: git progress lines use \r to overwrite in-place. Split on
|
||||
// both \r and \n to find the latest progress fragment, then extract
|
||||
// the phase name and percentage for the renderer.
|
||||
const lines = text.split(/[\r\n]+/)
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
|
||||
if (match && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:clone-progress', {
|
||||
phase: match[1].trim(),
|
||||
percent: parseInt(match[2], 10)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Why: git progress lines use \r to overwrite in-place; parse
|
||||
// fragments the same way for local and SSH clone flows.
|
||||
emitCloneProgressFromText(mainWindow, text)
|
||||
})
|
||||
|
||||
const finishClone = async (
|
||||
@@ -1784,7 +1792,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
_event,
|
||||
args: { connectionId: string; url: string; destination: string }
|
||||
): Promise<Repo> => {
|
||||
const repo = await cloneRemoteRepo(store, args)
|
||||
const repo = await cloneRemoteRepo(store, mainWindow, args)
|
||||
notifyReposChanged(mainWindow)
|
||||
return repo
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type MockMultiplexer = {
|
||||
request: ReturnType<typeof vi.fn>
|
||||
notify: ReturnType<typeof vi.fn>
|
||||
onNotification: ReturnType<typeof vi.fn>
|
||||
onNotificationByMethod: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
isDisposed: ReturnType<typeof vi.fn>
|
||||
}
|
||||
@@ -15,6 +16,7 @@ function createMockMux(): MockMultiplexer {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()),
|
||||
dispose: vi.fn(),
|
||||
isDisposed: vi.fn().mockReturnValue(false)
|
||||
}
|
||||
@@ -79,6 +81,48 @@ describe('SshGitProvider', () => {
|
||||
expect(result).toEqual(['dist/bundle.js'])
|
||||
})
|
||||
|
||||
it('clone sends git.clone request and forwards matching progress notifications', async () => {
|
||||
const unsubscribe = vi.fn()
|
||||
const onProgress = vi.fn()
|
||||
mux.onNotificationByMethod.mockReturnValue(unsubscribe)
|
||||
mux.request.mockImplementationOnce(async (_method, params) => {
|
||||
const progressHandler = mux.onNotificationByMethod.mock.calls[0][1]
|
||||
progressHandler({
|
||||
progressId: params.progressId,
|
||||
phase: 'Receiving objects',
|
||||
percent: 42
|
||||
})
|
||||
progressHandler({
|
||||
progressId: 'other-clone',
|
||||
phase: 'Receiving objects',
|
||||
percent: 99
|
||||
})
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
await provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user', {
|
||||
timeoutMs: 1000,
|
||||
onProgress
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'git.clone',
|
||||
expect.objectContaining({
|
||||
args: ['clone', '--progress', '--', 'url', 'repo'],
|
||||
cwd: '/home/user',
|
||||
progressId: expect.stringMatching(/^clone-/)
|
||||
}),
|
||||
{ signal: undefined, timeoutMs: 1000 }
|
||||
)
|
||||
expect(mux.onNotificationByMethod).toHaveBeenCalledWith(
|
||||
'git.cloneProgress',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(onProgress).toHaveBeenCalledWith({ phase: 'Receiving objects', percent: 42 })
|
||||
expect(onProgress).toHaveBeenCalledTimes(1)
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('getHistory sends git.history request', async () => {
|
||||
const historyResult = {
|
||||
items: [],
|
||||
|
||||
@@ -544,6 +544,43 @@ export class SshGitProvider implements IGitProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async clone(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
onProgress?: (progress: { phase: string; percent: number }) => void
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const unsubscribe = options?.onProgress
|
||||
? this.mux.onNotificationByMethod('git.cloneProgress', (params) => {
|
||||
if (params.progressId !== progressId) {
|
||||
return
|
||||
}
|
||||
const phase = params.phase
|
||||
const percent = params.percent
|
||||
if (typeof phase === 'string' && typeof percent === 'number') {
|
||||
options.onProgress?.({ phase, percent })
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
try {
|
||||
const result = await this.mux.request(
|
||||
'git.clone',
|
||||
{ args, cwd, progressId },
|
||||
{ signal: options?.signal, timeoutMs: options?.timeoutMs }
|
||||
)
|
||||
return result as {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
} finally {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}
|
||||
|
||||
async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> {
|
||||
return (await this.mux.request('git.isGitRepo', { dirPath })) as {
|
||||
isRepo: boolean
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('GitHandler', () => {
|
||||
expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate')
|
||||
expect(methods).toContain('git.renameCurrentBranch')
|
||||
expect(methods).toContain('git.exec')
|
||||
expect(methods).toContain('git.clone')
|
||||
expect(methods).toContain('git.isGitRepo')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC
|
||||
protocol surface so local and SSH git behavior stay in one dispatch table. */
|
||||
import { execFile } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import * as path from 'path'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
@@ -94,6 +94,7 @@ export class GitHandler {
|
||||
)
|
||||
this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p))
|
||||
this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context))
|
||||
this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context))
|
||||
this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p))
|
||||
}
|
||||
|
||||
@@ -595,6 +596,87 @@ export class GitHandler {
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
private async clone(params: Record<string, unknown>, context?: RequestContext) {
|
||||
const args = params.args as string[]
|
||||
const cwd = params.cwd as string
|
||||
const progressId = params.progressId
|
||||
validateGitExecArgs(args)
|
||||
if (typeof progressId !== 'string' || progressId.length === 0) {
|
||||
throw new Error('Missing clone progress id.')
|
||||
}
|
||||
if (args[0] !== 'clone') {
|
||||
throw new Error('git.clone only supports clone commands.')
|
||||
}
|
||||
return await this.spawnClone(args, cwd, progressId, context)
|
||||
}
|
||||
|
||||
private async spawnClone(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
progressId: string,
|
||||
context?: RequestContext
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, {
|
||||
cwd: expandTilde(cwd),
|
||||
env: buildRelayCommandEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
context?.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
child.kill()
|
||||
}
|
||||
context?.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout = (stdout + chunk.toString('utf-8')).slice(-4096)
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf-8')
|
||||
stderr = (stderr + text).slice(-4096)
|
||||
for (const line of text.split(/[\r\n]+/)) {
|
||||
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
|
||||
if (match) {
|
||||
this.dispatcher.notify('git.cloneProgress', {
|
||||
progressId,
|
||||
phase: match[1].trim(),
|
||||
percent: parseInt(match[2], 10)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
if (context?.signal?.aborted) {
|
||||
reject(new Error('Clone aborted'))
|
||||
return
|
||||
}
|
||||
if (code === 0 && !signal) {
|
||||
resolve({ stdout, stderr })
|
||||
return
|
||||
}
|
||||
const lastLine = stderr.trim().split('\n').pop() ?? 'unknown error'
|
||||
reject(new Error(`Clone failed: ${lastLine}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async renameCurrentBranch(params: Record<string, unknown>) {
|
||||
const worktreePath = params.worktreePath
|
||||
const newBranch = params.newBranch
|
||||
|
||||
Reference in New Issue
Block a user