diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 21bd359b233..6fbf48d1d0d 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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 () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 06605f5c5f6..c3a86e33540 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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() 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 => { - const repo = await cloneRemoteRepo(store, args) + const repo = await cloneRemoteRepo(store, mainWindow, args) notifyReposChanged(mainWindow) return repo } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 7193a63773d..616faddde02 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -6,6 +6,7 @@ type MockMultiplexer = { request: ReturnType notify: ReturnType onNotification: ReturnType + onNotificationByMethod: ReturnType dispose: ReturnType isDisposed: ReturnType } @@ -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: [], diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 51034cf9d58..cd8201677b9 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -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 diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 79d3d763a2b..db443ec89a2 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -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') }) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 032c1b3c6b8..ae4cf5784fa 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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, 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) { const worktreePath = params.worktreePath const newBranch = params.newBranch