@@ -17,7 +17,7 @@ import type {
1717import { RuntimeError as RuntimeErrorClass } from "./Runtime" ;
1818import { EXIT_CODE_ABORTED , EXIT_CODE_TIMEOUT } from "../constants/exitCodes" ;
1919import { log } from "../services/log" ;
20- import { checkInitHookExists , createLineBufferedLoggers , getInitHookEnv } from "./initHook" ;
20+ import { checkInitHookExists , createLineBufferedLoggers } from "./initHook" ;
2121import { streamProcessToLogger } from "./streamProcess" ;
2222import { expandTildeForSSH , cdCommandForSSH } from "./tildeExpansion" ;
2323import { getProjectName } from "../utils/runtime/helpers" ;
@@ -751,7 +751,6 @@ export class SSHRuntime implements Runtime {
751751 cwd : workspacePath , // Run in the workspace directory
752752 timeout : 3600 , // 1 hour - generous timeout for init hooks
753753 abortSignal,
754- env : getInitHookEnv ( projectPath , "ssh" ) ,
755754 } ) ;
756755
757756 // Create line-buffered loggers
@@ -856,32 +855,72 @@ export class SSHRuntime implements Runtime {
856855 }
857856
858857 async initWorkspace ( params : WorkspaceInitParams ) : Promise < WorkspaceInitResult > {
859- const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params ;
858+ const {
859+ projectPath,
860+ branchName,
861+ trunkBranch,
862+ workspacePath,
863+ initLogger,
864+ abortSignal,
865+ sourceWorkspacePath,
866+ } = params ;
860867
861868 try {
862- // 1. Sync project to remote (opportunistic rsync with scp fallback)
863- initLogger . logStep ( "Syncing project files to remote..." ) ;
864- try {
865- await this . syncProjectToRemote ( projectPath , workspacePath , initLogger , abortSignal ) ;
866- } catch ( error ) {
867- const errorMsg = getErrorMessage ( error ) ;
868- initLogger . logStderr ( `Failed to sync project: ${ errorMsg } ` ) ;
869- initLogger . logComplete ( - 1 ) ;
870- return {
871- success : false ,
872- error : `Failed to sync project: ${ errorMsg } ` ,
873- } ;
869+ // Fork scenario: Copy from source workspace instead of syncing from local
870+ if ( sourceWorkspacePath ) {
871+ // 1. Copy workspace directory on remote host
872+ // cp -a preserves all attributes (permissions, timestamps, symlinks, uncommitted changes)
873+ initLogger . logStep ( "Copying workspace from source..." ) ;
874+ // Expand tilde paths before using in remote command
875+ const expandedSourcePath = expandTildeForSSH ( sourceWorkspacePath ) ;
876+ const expandedWorkspacePath = expandTildeForSSH ( workspacePath ) ;
877+ const copyStream = await this . exec (
878+ `cp -a ${ expandedSourcePath } /. ${ expandedWorkspacePath } /` ,
879+ { cwd : "~" , timeout : 300 , abortSignal } // 5 minute timeout for large workspaces
880+ ) ;
881+
882+ const [ stdout , stderr , exitCode ] = await Promise . all ( [
883+ streamToString ( copyStream . stdout ) ,
884+ streamToString ( copyStream . stderr ) ,
885+ copyStream . exitCode ,
886+ ] ) ;
887+
888+ if ( exitCode !== 0 ) {
889+ const errorMsg = `Failed to copy workspace: ${ stderr || stdout } ` ;
890+ initLogger . logStderr ( errorMsg ) ;
891+ initLogger . logComplete ( - 1 ) ;
892+ return {
893+ success : false ,
894+ error : errorMsg ,
895+ } ;
896+ }
897+ initLogger . logStep ( "Workspace copied successfully" ) ;
898+ } else {
899+ // Normal scenario: Sync from local project
900+ // 1. Sync project to remote (opportunistic rsync with scp fallback)
901+ initLogger . logStep ( "Syncing project files to remote..." ) ;
902+ try {
903+ await this . syncProjectToRemote ( projectPath , workspacePath , initLogger , abortSignal ) ;
904+ } catch ( error ) {
905+ const errorMsg = getErrorMessage ( error ) ;
906+ initLogger . logStderr ( `Failed to sync project: ${ errorMsg } ` ) ;
907+ initLogger . logComplete ( - 1 ) ;
908+ return {
909+ success : false ,
910+ error : `Failed to sync project: ${ errorMsg } ` ,
911+ } ;
912+ }
913+ initLogger . logStep ( "Files synced successfully" ) ;
874914 }
875- initLogger . logStep ( "Files synced successfully" ) ;
876915
877916 // 2. Checkout branch remotely
878- // If branch exists locally, check it out; otherwise create it from the specified trunk branch
879- // Note: We've already created local branches for all remote refs in syncProjectToRemote
880917 initLogger . logStep ( `Checking out branch: ${ branchName } ` ) ;
881918
882- // Try to checkout existing branch, or create new branch from trunk
883- // Since we've created local branches for all remote refs, we can use branch names directly
884- const checkoutCmd = `git checkout ${ shescape . quote ( branchName ) } 2>/dev/null || git checkout -b ${ shescape . quote ( branchName ) } ${ shescape . quote ( trunkBranch ) } ` ;
919+ // For forked workspaces (copied with cp -a), HEAD is already on the source branch
920+ // For synced workspaces, we need to specify the trunk branch to create from
921+ const checkoutCmd = sourceWorkspacePath
922+ ? `git checkout ${ shescape . quote ( branchName ) } 2>/dev/null || git checkout -b ${ shescape . quote ( branchName ) } `
923+ : `git checkout ${ shescape . quote ( branchName ) } 2>/dev/null || git checkout -b ${ shescape . quote ( branchName ) } ${ shescape . quote ( trunkBranch ) } ` ;
885924
886925 const checkoutStream = await this . exec ( checkoutCmd , {
887926 cwd : workspacePath , // Use the full workspace path for git operations
@@ -1159,16 +1198,46 @@ export class SSHRuntime implements Runtime {
11591198 }
11601199 }
11611200
1162- forkWorkspace ( _params : WorkspaceForkParams ) : Promise < WorkspaceForkResult > {
1163- // SSH forking is not yet implemented due to unresolved complexities:
1164- // - Users expect the new workspace's filesystem state to match the remote workspace,
1165- // not the local project (which may be out of sync or on a different commit)
1166- // - This requires: detecting the branch, copying remote state, handling uncommitted changes
1167- // - For now, users should create a new workspace from the desired branch instead
1168- return Promise . resolve ( {
1169- success : false ,
1170- error : "Forking SSH workspaces is not yet implemented. Create a new workspace instead." ,
1171- } ) ;
1201+ async forkWorkspace ( params : WorkspaceForkParams ) : Promise < WorkspaceForkResult > {
1202+ const { projectPath, sourceWorkspaceName, newWorkspaceName, initLogger } = params ;
1203+
1204+ // Get source and destination workspace paths
1205+ const sourceWorkspacePath = this . getWorkspacePath ( projectPath , sourceWorkspaceName ) ;
1206+ const newWorkspacePath = this . getWorkspacePath ( projectPath , newWorkspaceName ) ;
1207+
1208+ // Expand tilde path for the new workspace directory
1209+ const expandedNewPath = expandTildeForSSH ( newWorkspacePath ) ;
1210+
1211+ try {
1212+ // Step 1: Create empty directory for new workspace (instant)
1213+ // The actual copy happens in initWorkspace (fire-and-forget)
1214+ initLogger . logStep ( "Creating workspace directory..." ) ;
1215+ const mkdirStream = await this . exec ( `mkdir -p ${ expandedNewPath } ` , { cwd : "~" , timeout : 10 } ) ;
1216+
1217+ await mkdirStream . stdin . abort ( ) ;
1218+ const mkdirExitCode = await mkdirStream . exitCode ;
1219+ if ( mkdirExitCode !== 0 ) {
1220+ const stderr = await streamToString ( mkdirStream . stderr ) ;
1221+ return {
1222+ success : false ,
1223+ error : `Failed to create workspace directory: ${ stderr } ` ,
1224+ } ;
1225+ }
1226+
1227+ initLogger . logStep ( "Workspace directory created" ) ;
1228+
1229+ // Return immediately - copy and init happen in initWorkspace (fire-and-forget)
1230+ return {
1231+ success : true ,
1232+ workspacePath : newWorkspacePath ,
1233+ sourceWorkspacePath,
1234+ } ;
1235+ } catch ( error ) {
1236+ return {
1237+ success : false ,
1238+ error : getErrorMessage ( error ) ,
1239+ } ;
1240+ }
11721241 }
11731242}
11741243
0 commit comments