Skip to content

Commit fe04156

Browse files
committed
🤖 feat: optimize workspace fork init and tests
1 parent 31680dd commit fe04156

16 files changed

+1502
-691
lines changed

docs/AGENTS.md

Lines changed: 493 additions & 92 deletions
Large diffs are not rendered by default.

src/constants/ipc-constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,8 @@ export const IPC_CHANNELS = {
6767
// Helper functions for dynamic channels
6868
export const getChatChannel = (workspaceId: string): string =>
6969
`${IPC_CHANNELS.WORKSPACE_CHAT_PREFIX}${workspaceId}`;
70+
71+
// Event type constants for workspace init events
72+
export const EVENT_TYPE_PREFIX_INIT = "init-";
73+
export const EVENT_TYPE_INIT_OUTPUT = "init-output" as const;
74+
export const EVENT_TYPE_INIT_END = "init-end" as const;

src/runtime/LocalRuntime.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,9 @@ import type {
1818
} from "./Runtime";
1919
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
2020
import { NON_INTERACTIVE_ENV_VARS } from "../constants/env";
21-
import { getBashPath } from "../utils/main/bashPath";
2221
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "../constants/exitCodes";
2322
import { listLocalBranches } from "../git";
24-
import {
25-
checkInitHookExists,
26-
getInitHookPath,
27-
createLineBufferedLoggers,
28-
getInitHookEnv,
29-
} from "./initHook";
23+
import { checkInitHookExists, getInitHookPath, createLineBufferedLoggers } from "./initHook";
3024
import { execAsync, DisposableProcess } from "../utils/disposableExec";
3125
import { getProjectName } from "../utils/runtime/helpers";
3226
import { getErrorMessage } from "../utils/errors";
@@ -62,13 +56,11 @@ export class LocalRuntime implements Runtime {
6256
);
6357
}
6458

65-
// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
66-
// Windows doesn't have nice command, so just spawn bash directly
67-
const isWindows = process.platform === "win32";
68-
const bashPath = getBashPath();
69-
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath;
59+
// If niceness is specified, spawn nice directly to avoid escaping issues
60+
const spawnCommand = options.niceness !== undefined ? "nice" : "bash";
61+
const bashPath = "bash";
7062
const spawnArgs =
71-
options.niceness !== undefined && !isWindows
63+
options.niceness !== undefined
7264
? ["-n", options.niceness.toString(), bashPath, "-c", command]
7365
: ["-c", command];
7466

@@ -378,7 +370,10 @@ export class LocalRuntime implements Runtime {
378370
const { projectPath, workspacePath, initLogger } = params;
379371

380372
try {
381-
// Run .mux/init hook if it exists
373+
// Note: sourceWorkspacePath is only used by SSH runtime (to copy workspace)
374+
// Local runtime creates git worktrees which are instant, so we don't need it here
375+
376+
// Run .cmux/init hook if it exists
382377
// Note: runInitHook calls logComplete() internally if hook exists
383378
const hookExists = await checkInitHookExists(projectPath);
384379
if (hookExists) {
@@ -400,7 +395,7 @@ export class LocalRuntime implements Runtime {
400395
}
401396

402397
/**
403-
* Run .mux/init hook if it exists and is executable
398+
* Run .cmux/init hook if it exists and is executable
404399
*/
405400
private async runInitHook(
406401
projectPath: string,
@@ -420,14 +415,9 @@ export class LocalRuntime implements Runtime {
420415
const loggers = createLineBufferedLoggers(initLogger);
421416

422417
return new Promise<void>((resolve) => {
423-
const bashPath = getBashPath();
424-
const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
418+
const proc = spawn("bash", ["-c", `"${hookPath}"`], {
425419
cwd: workspacePath,
426420
stdio: ["ignore", "pipe", "pipe"],
427-
env: {
428-
...process.env,
429-
...getInitHookEnv(projectPath, "local"),
430-
},
431421
});
432422

433423
proc.stdout.on("data", (data: Buffer) => {
@@ -601,7 +591,10 @@ export class LocalRuntime implements Runtime {
601591
};
602592
}
603593

594+
initLogger.logStep(`Detected source branch: ${sourceBranch}`);
595+
604596
// Use createWorkspace with sourceBranch as trunk to fork from source branch
597+
// For local workspaces (worktrees), this is instant - no init needed
605598
const createResult = await this.createWorkspace({
606599
projectPath,
607600
branchName: newWorkspaceName,
@@ -617,9 +610,12 @@ export class LocalRuntime implements Runtime {
617610
};
618611
}
619612

613+
initLogger.logStep("Workspace forked successfully");
614+
620615
return {
621616
success: true,
622617
workspacePath: createResult.workspacePath,
618+
sourceWorkspacePath,
623619
sourceBranch,
624620
};
625621
} catch (error) {

src/runtime/Runtime.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ export interface WorkspaceInitParams {
151151
initLogger: InitLogger;
152152
/** Optional abort signal for cancellation */
153153
abortSignal?: AbortSignal;
154+
/** If provided, copy from this workspace instead of syncing from local (fork scenario) */
155+
sourceWorkspacePath?: string;
154156
}
155157

156158
/**
@@ -189,6 +191,8 @@ export interface WorkspaceForkResult {
189191
success: boolean;
190192
/** Path to the new workspace (if successful) */
191193
workspacePath?: string;
194+
/** Path to the source workspace (needed for init phase) */
195+
sourceWorkspacePath?: string;
192196
/** Branch that was forked from */
193197
sourceBranch?: string;
194198
/** Error message (if failed) */
@@ -304,7 +308,7 @@ export interface Runtime {
304308
/**
305309
* Initialize workspace asynchronously (may be slow, streams progress)
306310
* - LocalRuntime: Runs init hook if present
307-
* - SSHRuntime: Syncs files, checks out branch, runs init hook
311+
* - SSHRuntime: Syncs files (or copies from source), checks out branch, runs init hook
308312
* Streams progress via initLogger.
309313
* @param params Workspace initialization parameters
310314
* @returns Result indicating success or error

src/runtime/SSHRuntime.ts

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
1818
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "../constants/exitCodes";
1919
import { log } from "../services/log";
20-
import { checkInitHookExists, createLineBufferedLoggers, getInitHookEnv } from "./initHook";
20+
import { checkInitHookExists, createLineBufferedLoggers } from "./initHook";
2121
import { streamProcessToLogger } from "./streamProcess";
2222
import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion";
2323
import { 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

src/runtime/initHook.ts

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@ import * as path from "path";
44
import type { InitLogger } from "./Runtime";
55

66
/**
7-
* Check if .mux/init hook exists and is executable
7+
* Constants for init hook functionality
8+
*/
9+
export const CMUX_DIR = ".mux";
10+
export const INIT_HOOK_FILENAME = "init";
11+
12+
/**
13+
* Check if .cmux/init hook exists and is executable
814
* @param projectPath - Path to the project root
915
* @returns true if hook exists and is executable, false otherwise
1016
*/
1117
export async function checkInitHookExists(projectPath: string): Promise<boolean> {
12-
const hookPath = path.join(projectPath, ".mux", "init");
18+
const hookPath = path.join(projectPath, CMUX_DIR, INIT_HOOK_FILENAME);
1319

1420
try {
1521
await fsPromises.access(hookPath, fs.constants.X_OK);
@@ -23,23 +29,7 @@ export async function checkInitHookExists(projectPath: string): Promise<boolean>
2329
* Get the init hook path for a project
2430
*/
2531
export function getInitHookPath(projectPath: string): string {
26-
return path.join(projectPath, ".mux", "init");
27-
}
28-
29-
/**
30-
* Get environment variables for init hook execution
31-
* Centralizes env var injection to avoid duplication across runtimes
32-
* @param projectPath - Path to project root (local path for LocalRuntime, remote path for SSHRuntime)
33-
* @param runtime - Runtime type: "local" or "ssh"
34-
*/
35-
export function getInitHookEnv(
36-
projectPath: string,
37-
runtime: "local" | "ssh"
38-
): Record<string, string> {
39-
return {
40-
MUX_PROJECT_PATH: projectPath,
41-
MUX_RUNTIME: runtime,
42-
};
32+
return path.join(projectPath, CMUX_DIR, INIT_HOOK_FILENAME);
4333
}
4434

4535
/**

0 commit comments

Comments
 (0)