Skip to content

Commit 7571937

Browse files
authored
🤖 feat: fast-forward to latest origin on workspace creation (#800)
Fetch and fast-forward merge origin/<trunk> when creating workspaces. Failures are logged but never block workspace creation. _Generated with `mux`_
1 parent 38dd003 commit 7571937

File tree

3 files changed

+112
-3
lines changed

3 files changed

+112
-3
lines changed

src/node/runtime/LocalRuntime.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,9 @@ export class LocalRuntime implements Runtime {
365365

366366
initLogger.logStep("Worktree created successfully");
367367

368+
// Pull latest from origin (best-effort, non-blocking on failure)
369+
await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger);
370+
368371
return { success: true, workspacePath };
369372
} catch (error) {
370373
return {
@@ -374,6 +377,45 @@ export class LocalRuntime implements Runtime {
374377
}
375378
}
376379

380+
/**
381+
* Fetch and rebase on latest origin/<trunkBranch>
382+
* Best-effort operation - logs status but doesn't fail workspace creation
383+
*/
384+
private async pullLatestFromOrigin(
385+
workspacePath: string,
386+
trunkBranch: string,
387+
initLogger: InitLogger
388+
): Promise<void> {
389+
try {
390+
initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`);
391+
392+
// Fetch the trunk branch from origin
393+
using fetchProc = execAsync(`git -C "${workspacePath}" fetch origin "${trunkBranch}"`);
394+
await fetchProc.result;
395+
396+
initLogger.logStep("Fast-forward merging...");
397+
398+
// Attempt fast-forward merge from origin/<trunkBranch>
399+
try {
400+
using mergeProc = execAsync(
401+
`git -C "${workspacePath}" merge --ff-only "origin/${trunkBranch}"`
402+
);
403+
await mergeProc.result;
404+
initLogger.logStep("Fast-forwarded to latest origin successfully");
405+
} catch (mergeError) {
406+
// Fast-forward not possible (diverged branches) - just warn
407+
const errorMsg = getErrorMessage(mergeError);
408+
initLogger.logStderr(`Note: Fast-forward skipped (${errorMsg}), using local branch state`);
409+
}
410+
} catch (error) {
411+
// Fetch failed - log and continue (common for repos without remote)
412+
const errorMsg = getErrorMessage(error);
413+
initLogger.logStderr(
414+
`Note: Could not fetch from origin (${errorMsg}), using local branch state`
415+
);
416+
}
417+
}
418+
377419
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
378420
const { projectPath, workspacePath, initLogger } = params;
379421

src/node/runtime/SSHRuntime.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,10 @@ export class SSHRuntime implements Runtime {
906906
}
907907
initLogger.logStep("Branch checked out successfully");
908908

909-
// 3. Run .mux/init hook if it exists
909+
// 3. Pull latest from origin (best-effort, non-blocking on failure)
910+
await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger, abortSignal);
911+
912+
// 4. Run .mux/init hook if it exists
910913
// Note: runInitHook calls logComplete() internally if hook exists
911914
const hookExists = await checkInitHookExists(projectPath);
912915
if (hookExists) {
@@ -928,6 +931,68 @@ export class SSHRuntime implements Runtime {
928931
}
929932
}
930933

934+
/**
935+
* Fetch and rebase on latest origin/<trunkBranch> on remote
936+
* Best-effort operation - logs status but doesn't fail workspace initialization
937+
*/
938+
private async pullLatestFromOrigin(
939+
workspacePath: string,
940+
trunkBranch: string,
941+
initLogger: InitLogger,
942+
abortSignal?: AbortSignal
943+
): Promise<void> {
944+
try {
945+
initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`);
946+
947+
// Fetch the trunk branch from origin
948+
const fetchCmd = `git fetch origin ${shescape.quote(trunkBranch)}`;
949+
const fetchStream = await this.exec(fetchCmd, {
950+
cwd: workspacePath,
951+
timeout: 120, // 2 minutes for network operation
952+
abortSignal,
953+
});
954+
955+
const fetchExitCode = await fetchStream.exitCode;
956+
if (fetchExitCode !== 0) {
957+
const fetchStderr = await streamToString(fetchStream.stderr);
958+
initLogger.logStderr(
959+
`Note: Could not fetch from origin (${fetchStderr}), using local branch state`
960+
);
961+
return;
962+
}
963+
964+
initLogger.logStep("Fast-forward merging...");
965+
966+
// Attempt fast-forward merge from origin/<trunkBranch>
967+
const mergeCmd = `git merge --ff-only origin/${shescape.quote(trunkBranch)}`;
968+
const mergeStream = await this.exec(mergeCmd, {
969+
cwd: workspacePath,
970+
timeout: 60, // 1 minute for fast-forward merge
971+
abortSignal,
972+
});
973+
974+
const [mergeStderr, mergeExitCode] = await Promise.all([
975+
streamToString(mergeStream.stderr),
976+
mergeStream.exitCode,
977+
]);
978+
979+
if (mergeExitCode !== 0) {
980+
// Fast-forward not possible (diverged branches) - just warn
981+
initLogger.logStderr(
982+
`Note: Fast-forward skipped (${mergeStderr || "branches diverged"}), using local branch state`
983+
);
984+
} else {
985+
initLogger.logStep("Fast-forwarded to latest origin successfully");
986+
}
987+
} catch (error) {
988+
// Non-fatal: log and continue
989+
const errorMsg = getErrorMessage(error);
990+
initLogger.logStderr(
991+
`Note: Could not fetch from origin (${errorMsg}), using local branch state`
992+
);
993+
}
994+
}
995+
931996
async renameWorkspace(
932997
projectPath: string,
933998
oldName: string,

tests/ipcMain/initWorkspace.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ describeIntegration("IpcMain workspace init hook integration tests", () => {
174174
expect(outputLines).toContain("Installing dependencies...");
175175
expect(outputLines).toContain("Build complete!");
176176

177-
expect(errorEvents.length).toBe(1);
178-
expect(errorEvents[0].line).toBe("Warning: deprecated package");
177+
// Should have at least the hook's stderr message
178+
// (may also have pull-latest notes if fetch/rebase fails, which is expected)
179+
const hookErrorEvent = errorEvents.find((e) => e.line === "Warning: deprecated package");
180+
expect(hookErrorEvent).toBeDefined();
179181

180182
// Last event should be end with exitCode 0
181183
const finalEvent = initEvents[initEvents.length - 1];

0 commit comments

Comments
 (0)