@@ -47,6 +47,20 @@ export class SSHRuntime implements Runtime {
4747 this . config = config ;
4848 }
4949
50+
51+ /**
52+ * Expand tilde in path for use in remote commands
53+ * Bash doesn't expand ~ when it's inside quotes, so we need to do it manually
54+ */
55+ private expandTilde ( path : string ) : string {
56+ if ( path === "~" ) {
57+ return "$HOME" ;
58+ } else if ( path . startsWith ( "~/" ) ) {
59+ return "$HOME/" + path . slice ( 2 ) ;
60+ }
61+ return path ;
62+ }
63+
5064 /**
5165 * Execute command over SSH with streaming I/O
5266 */
@@ -63,10 +77,7 @@ export class SSHRuntime implements Runtime {
6377 }
6478
6579 // Expand ~/path to $HOME/path before quoting (~ doesn't expand in quotes)
66- let cwd = options . cwd ;
67- if ( cwd . startsWith ( "~/" ) ) {
68- cwd = "$HOME/" + cwd . slice ( 2 ) ;
69- }
80+ const cwd = this . expandTilde ( options . cwd ) ;
7081
7182 // Build full command with cwd and env
7283 const fullCommand = `cd ${ JSON . stringify ( cwd ) } && ${ envPrefix } ${ command } ` ;
@@ -313,56 +324,91 @@ export class SSHRuntime implements Runtime {
313324 * - Simpler implementation
314325 */
315326 private async syncProjectToRemote ( projectPath : string , initLogger : InitLogger ) : Promise < void > {
316- return new Promise < void > ( ( resolve , reject ) => {
317- // Build SSH args
318- const sshArgs = this . buildSSHArgs ( true ) ;
319-
320- // For paths starting with ~/, expand to $HOME
321- let remoteWorkdir : string ;
322- if ( this . config . workdir . startsWith ( "~/" ) ) {
323- const pathWithoutTilde = this . config . workdir . slice ( 2 ) ;
324- remoteWorkdir = `"\\\\$HOME/${ pathWithoutTilde } "` ; // Escape $ so local shell doesn't expand it
325- } else {
326- remoteWorkdir = JSON . stringify ( this . config . workdir ) ;
327- }
327+ // Use timestamp-based bundle path to avoid conflicts (simpler than $$)
328+ const timestamp = Date . now ( ) ;
329+ const bundleTempPath = `~/.cmux-bundle-${ timestamp } .bundle` ;
328330
329- // Use git bundle to create a packfile of all refs, pipe through ssh for cloning
330- // This creates a real git repository on the remote with full history
331- // Wrap remote commands in bash to avoid issues with non-bash shells (fish, zsh, etc)
332- // Save bundle to temp file on remote, clone from it, then clean up
333- // Use $$ for PID to avoid conflicts, escape $ so it's evaluated on remote
334- const bundleTempPath = `\\$HOME/.cmux-bundle-\\$\\$.bundle` ;
335- const command = `cd ${ JSON . stringify ( projectPath ) } && git bundle create - --all | ssh ${ sshArgs . join ( " " ) } "bash -c 'cat > ${ bundleTempPath } && git clone --quiet ${ bundleTempPath } ${ remoteWorkdir } && rm ${ bundleTempPath } '"` ;
336-
337- log . debug ( `Starting git bundle+ssh: ${ command } ` ) ;
338- const proc = spawn ( "bash" , [ "-c" , command ] ) ;
339-
340- // Use helper to stream output and prevent buffer overflow
341- streamProcessToLogger ( proc , initLogger , {
342- logStdout : false , // bundle stdout is binary, drain silently
343- logStderr : true , // Errors go to init stream
344- command : `git bundle+ssh: ${ command } ` , // Log the full command
345- } ) ;
331+ try {
332+ // Step 1: Create bundle locally and pipe to remote file via SSH
333+ initLogger . logStep ( `Creating git bundle...` ) ;
334+ await new Promise < void > ( ( resolve , reject ) => {
335+ const sshArgs = this . buildSSHArgs ( true ) ;
336+ const command = `cd ${ JSON . stringify ( projectPath ) } && git bundle create - --all | ssh ${ sshArgs . join ( " " ) } "cat > ${ bundleTempPath } "` ;
337+
338+ log . debug ( `Creating bundle: ${ command } ` ) ;
339+ const proc = spawn ( "bash" , [ "-c" , command ] ) ;
340+
341+ streamProcessToLogger ( proc , initLogger , {
342+ logStdout : false ,
343+ logStderr : true ,
344+ } ) ;
346345
347- let stderr = "" ;
348- proc . stderr . on ( "data" , ( data : Buffer ) => {
349- stderr += data . toString ( ) ;
346+ let stderr = "" ;
347+ proc . stderr . on ( "data" , ( data : Buffer ) => {
348+ stderr += data . toString ( ) ;
349+ } ) ;
350+
351+ proc . on ( "close" , ( code ) => {
352+ if ( code === 0 ) {
353+ resolve ( ) ;
354+ } else {
355+ reject ( new Error ( `Failed to create bundle: ${ stderr } ` ) ) ;
356+ }
357+ } ) ;
358+
359+ proc . on ( "error" , ( err ) => {
360+ reject ( err ) ;
361+ } ) ;
350362 } ) ;
351363
352- proc . on ( "close" , ( code ) => {
353- if ( code === 0 ) {
354- resolve ( ) ;
355- } else {
356- reject ( new Error ( `git bundle+ssh failed with exit code ${ code ?? "unknown" } : ${ stderr } ` ) ) ;
357- }
364+ // Step 2: Clone from bundle on remote using this.exec (handles tilde expansion)
365+ initLogger . logStep ( `Cloning repository on remote...` ) ;
366+ const expandedWorkdir = this . expandTilde ( this . config . workdir ) ;
367+ const cloneStream = this . exec ( `git clone --quiet ${ bundleTempPath } ${ JSON . stringify ( expandedWorkdir ) } ` , {
368+ cwd : "~" ,
369+ timeout : 300 , // 5 minutes for clone
358370 } ) ;
359371
360- proc . on ( "error" , ( err ) => {
361- reject ( err ) ;
372+ const [ cloneStdout , cloneStderr , cloneExitCode ] = await Promise . all ( [
373+ streamToString ( cloneStream . stdout ) ,
374+ streamToString ( cloneStream . stderr ) ,
375+ cloneStream . exitCode ,
376+ ] ) ;
377+
378+ if ( cloneExitCode !== 0 ) {
379+ throw new Error ( `Failed to clone repository: ${ cloneStderr || cloneStdout } ` ) ;
380+ }
381+
382+ // Step 3: Remove bundle file
383+ initLogger . logStep ( `Cleaning up bundle file...` ) ;
384+ const rmStream = this . exec ( `rm ${ bundleTempPath } ` , {
385+ cwd : "~" ,
386+ timeout : 10 ,
362387 } ) ;
363- } ) ;
388+
389+ const rmExitCode = await rmStream . exitCode ;
390+ if ( rmExitCode !== 0 ) {
391+ log . info ( `Failed to remove bundle file ${ bundleTempPath } , but continuing` ) ;
392+ }
393+
394+ initLogger . logStep ( `Repository cloned successfully` ) ;
395+ } catch ( error ) {
396+ // Try to clean up bundle file on error
397+ try {
398+ const rmStream = this . exec ( `rm -f ${ bundleTempPath } ` , {
399+ cwd : "~" ,
400+ timeout : 10 ,
401+ } ) ;
402+ await rmStream . exitCode ;
403+ } catch {
404+ // Ignore cleanup errors
405+ }
406+
407+ throw error ;
408+ }
364409 }
365410
411+
366412 /**
367413 * Run .cmux/init hook on remote machine if it exists
368414 */
0 commit comments