Stream SSH clone progress to renderer

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:34:00 -07:00
co-authored by Orca
parent e00d3cbd4c
commit 5d2ee98e11
6 changed files with 229 additions and 28 deletions
+40 -11
View File
@@ -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
View File
@@ -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: [],
+37
View File
@@ -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
+1
View File
@@ -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')
})
+83 -1
View File
@@ -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