@@ -24,6 +24,7 @@ import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion";
2424import { findBashPath } from "./executablePaths" ;
2525import { getProjectName } from "../utils/runtime/helpers" ;
2626import { getErrorMessage } from "../utils/errors" ;
27+ import { execAsync } from "../utils/disposableExec" ;
2728
2829/**
2930 * Shescape instance for bash shell escaping.
@@ -317,6 +318,41 @@ export class SSHRuntime implements Runtime {
317318 } ;
318319 }
319320
321+ normalizePath ( targetPath : string , basePath : string ) : string {
322+ // For SSH, handle paths in a POSIX-like manner without accessing the remote filesystem
323+ const target = targetPath . trim ( ) ;
324+ let base = basePath . trim ( ) ;
325+
326+ // Normalize base path - remove trailing slash (except for root "/")
327+ if ( base . length > 1 && base . endsWith ( "/" ) ) {
328+ base = base . slice ( 0 , - 1 ) ;
329+ }
330+
331+ // Handle special case: current directory
332+ if ( target === "." ) {
333+ return base ;
334+ }
335+
336+ // Handle tilde expansion - keep as-is for comparison
337+ let normalizedTarget = target ;
338+ if ( target === "~" || target . startsWith ( "~/" ) ) {
339+ normalizedTarget = target ;
340+ } else if ( target . startsWith ( "/" ) ) {
341+ // Absolute path - use as-is
342+ normalizedTarget = target ;
343+ } else {
344+ // Relative path - resolve against base using POSIX path joining
345+ normalizedTarget = base . endsWith ( "/" ) ? base + target : base + "/" + target ;
346+ }
347+
348+ // Remove trailing slash for comparison (except for root "/")
349+ if ( normalizedTarget . length > 1 && normalizedTarget . endsWith ( "/" ) ) {
350+ normalizedTarget = normalizedTarget . slice ( 0 , - 1 ) ;
351+ }
352+
353+ return normalizedTarget ;
354+ }
355+
320356 /**
321357 * Build common SSH arguments based on runtime config
322358 * @param includeHost - Whether to include the host in the args (for direct ssh commands)
@@ -372,11 +408,28 @@ export class SSHRuntime implements Runtime {
372408 const bundleTempPath = `~/.cmux-bundle-${ timestamp } .bundle` ;
373409
374410 try {
375- // Step 1: Create bundle locally and pipe to remote file via SSH
411+ // Step 1: Get origin URL from local repository (if it exists)
412+ let originUrl : string | null = null ;
413+ try {
414+ using proc = execAsync (
415+ `cd ${ shescape . quote ( projectPath ) } && git remote get-url origin 2>/dev/null || true`
416+ ) ;
417+ const { stdout } = await proc . result ;
418+ const url = stdout . trim ( ) ;
419+ // Only use URL if it's not a bundle path (avoids propagating bundle paths)
420+ if ( url && ! url . includes ( ".bundle" ) && ! url . includes ( ".cmux-bundle" ) ) {
421+ originUrl = url ;
422+ }
423+ } catch ( error ) {
424+ // If we can't get origin, continue without it
425+ initLogger . logStderr ( `Could not get origin URL: ${ getErrorMessage ( error ) } ` ) ;
426+ }
427+
428+ // Step 2: Create bundle locally and pipe to remote file via SSH
376429 initLogger . logStep ( `Creating git bundle...` ) ;
377430 await new Promise < void > ( ( resolve , reject ) => {
378431 const sshArgs = this . buildSSHArgs ( true ) ;
379- const command = `cd ${ JSON . stringify ( projectPath ) } && git bundle create - --all | ssh ${ sshArgs . join ( " " ) } "cat > ${ bundleTempPath } "` ;
432+ const command = `cd ${ shescape . quote ( projectPath ) } && git bundle create - --all | ssh ${ sshArgs . join ( " " ) } "cat > ${ bundleTempPath } "` ;
380433
381434 log . debug ( `Creating bundle: ${ command } ` ) ;
382435 const bashPath = findBashPath ( ) ;
@@ -405,7 +458,7 @@ export class SSHRuntime implements Runtime {
405458 } ) ;
406459 } ) ;
407460
408- // Step 2 : Clone from bundle on remote using this.exec
461+ // Step 3 : Clone from bundle on remote using this.exec
409462 initLogger . logStep ( `Cloning repository on remote...` ) ;
410463
411464 // Expand tilde in destination path for git clone
@@ -427,7 +480,37 @@ export class SSHRuntime implements Runtime {
427480 throw new Error ( `Failed to clone repository: ${ cloneStderr || cloneStdout } ` ) ;
428481 }
429482
430- // Step 3: Remove bundle file
483+ // Step 4: Update origin remote if we have an origin URL
484+ if ( originUrl ) {
485+ initLogger . logStep ( `Setting origin remote to ${ originUrl } ...` ) ;
486+ const setOriginStream = await this . exec (
487+ `git -C ${ cloneDestPath } remote set-url origin ${ shescape . quote ( originUrl ) } ` ,
488+ {
489+ cwd : "~" ,
490+ timeout : 10 ,
491+ }
492+ ) ;
493+
494+ const setOriginExitCode = await setOriginStream . exitCode ;
495+ if ( setOriginExitCode !== 0 ) {
496+ const stderr = await streamToString ( setOriginStream . stderr ) ;
497+ log . info ( `Failed to set origin remote: ${ stderr } ` ) ;
498+ // Continue anyway - this is not fatal
499+ }
500+ } else {
501+ // No origin in local repo, remove the origin that points to bundle
502+ initLogger . logStep ( `Removing bundle origin remote...` ) ;
503+ const removeOriginStream = await this . exec (
504+ `git -C ${ cloneDestPath } remote remove origin 2>/dev/null || true` ,
505+ {
506+ cwd : "~" ,
507+ timeout : 10 ,
508+ }
509+ ) ;
510+ await removeOriginStream . exitCode ;
511+ }
512+
513+ // Step 5: Remove bundle file
431514 initLogger . logStep ( `Cleaning up bundle file...` ) ;
432515 const rmStream = await this . exec ( `rm ${ bundleTempPath } ` , {
433516 cwd : "~" ,
@@ -615,7 +698,7 @@ export class SSHRuntime implements Runtime {
615698 // We create new branches from HEAD instead of the trunkBranch name to avoid issues
616699 // where the local repo's trunk name doesn't match the cloned repo's default branch
617700 initLogger . logStep ( `Checking out branch: ${ branchName } ` ) ;
618- const checkoutCmd = `(git checkout ${ JSON . stringify ( branchName ) } 2>/dev/null || git checkout -b ${ JSON . stringify ( branchName ) } HEAD)` ;
701+ const checkoutCmd = `(git checkout ${ shescape . quote ( branchName ) } 2>/dev/null || git checkout -b ${ shescape . quote ( branchName ) } HEAD)` ;
619702
620703 const checkoutStream = await this . exec ( checkoutCmd , {
621704 cwd : workspacePath , // Use the full workspace path for git operations
@@ -826,7 +909,7 @@ export class SSHRuntime implements Runtime {
826909/**
827910 * Helper to convert a ReadableStream to a string
828911 */
829- async function streamToString ( stream : ReadableStream < Uint8Array > ) : Promise < string > {
912+ export async function streamToString ( stream : ReadableStream < Uint8Array > ) : Promise < string > {
830913 const reader = stream . getReader ( ) ;
831914 const decoder = new TextDecoder ( "utf-8" ) ;
832915 let result = "" ;
0 commit comments