diff --git a/src/node/runtime/LocalRuntime.ts b/src/node/runtime/LocalRuntime.ts index 81012cd12d..8ea9eff5a9 100644 --- a/src/node/runtime/LocalRuntime.ts +++ b/src/node/runtime/LocalRuntime.ts @@ -365,6 +365,9 @@ export class LocalRuntime implements Runtime { initLogger.logStep("Worktree created successfully"); + // Pull latest from origin (best-effort, non-blocking on failure) + await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger); + return { success: true, workspacePath }; } catch (error) { return { @@ -374,6 +377,45 @@ export class LocalRuntime implements Runtime { } } + /** + * Fetch and rebase on latest origin/ + * Best-effort operation - logs status but doesn't fail workspace creation + */ + private async pullLatestFromOrigin( + workspacePath: string, + trunkBranch: string, + initLogger: InitLogger + ): Promise { + try { + initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`); + + // Fetch the trunk branch from origin + using fetchProc = execAsync(`git -C "${workspacePath}" fetch origin "${trunkBranch}"`); + await fetchProc.result; + + initLogger.logStep("Fast-forward merging..."); + + // Attempt fast-forward merge from origin/ + try { + using mergeProc = execAsync( + `git -C "${workspacePath}" merge --ff-only "origin/${trunkBranch}"` + ); + await mergeProc.result; + initLogger.logStep("Fast-forwarded to latest origin successfully"); + } catch (mergeError) { + // Fast-forward not possible (diverged branches) - just warn + const errorMsg = getErrorMessage(mergeError); + initLogger.logStderr(`Note: Fast-forward skipped (${errorMsg}), using local branch state`); + } + } catch (error) { + // Fetch failed - log and continue (common for repos without remote) + const errorMsg = getErrorMessage(error); + initLogger.logStderr( + `Note: Could not fetch from origin (${errorMsg}), using local branch state` + ); + } + } + async initWorkspace(params: WorkspaceInitParams): Promise { const { projectPath, workspacePath, initLogger } = params; diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 22588e8737..08fe71cf2d 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -906,7 +906,10 @@ export class SSHRuntime implements Runtime { } initLogger.logStep("Branch checked out successfully"); - // 3. Run .mux/init hook if it exists + // 3. Pull latest from origin (best-effort, non-blocking on failure) + await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger, abortSignal); + + // 4. Run .mux/init hook if it exists // Note: runInitHook calls logComplete() internally if hook exists const hookExists = await checkInitHookExists(projectPath); if (hookExists) { @@ -928,6 +931,68 @@ export class SSHRuntime implements Runtime { } } + /** + * Fetch and rebase on latest origin/ on remote + * Best-effort operation - logs status but doesn't fail workspace initialization + */ + private async pullLatestFromOrigin( + workspacePath: string, + trunkBranch: string, + initLogger: InitLogger, + abortSignal?: AbortSignal + ): Promise { + try { + initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`); + + // Fetch the trunk branch from origin + const fetchCmd = `git fetch origin ${shescape.quote(trunkBranch)}`; + const fetchStream = await this.exec(fetchCmd, { + cwd: workspacePath, + timeout: 120, // 2 minutes for network operation + abortSignal, + }); + + const fetchExitCode = await fetchStream.exitCode; + if (fetchExitCode !== 0) { + const fetchStderr = await streamToString(fetchStream.stderr); + initLogger.logStderr( + `Note: Could not fetch from origin (${fetchStderr}), using local branch state` + ); + return; + } + + initLogger.logStep("Fast-forward merging..."); + + // Attempt fast-forward merge from origin/ + const mergeCmd = `git merge --ff-only origin/${shescape.quote(trunkBranch)}`; + const mergeStream = await this.exec(mergeCmd, { + cwd: workspacePath, + timeout: 60, // 1 minute for fast-forward merge + abortSignal, + }); + + const [mergeStderr, mergeExitCode] = await Promise.all([ + streamToString(mergeStream.stderr), + mergeStream.exitCode, + ]); + + if (mergeExitCode !== 0) { + // Fast-forward not possible (diverged branches) - just warn + initLogger.logStderr( + `Note: Fast-forward skipped (${mergeStderr || "branches diverged"}), using local branch state` + ); + } else { + initLogger.logStep("Fast-forwarded to latest origin successfully"); + } + } catch (error) { + // Non-fatal: log and continue + const errorMsg = getErrorMessage(error); + initLogger.logStderr( + `Note: Could not fetch from origin (${errorMsg}), using local branch state` + ); + } + } + async renameWorkspace( projectPath: string, oldName: string, diff --git a/tests/ipcMain/initWorkspace.test.ts b/tests/ipcMain/initWorkspace.test.ts index 3e7c8b21e5..9a735cc0be 100644 --- a/tests/ipcMain/initWorkspace.test.ts +++ b/tests/ipcMain/initWorkspace.test.ts @@ -174,8 +174,10 @@ describeIntegration("IpcMain workspace init hook integration tests", () => { expect(outputLines).toContain("Installing dependencies..."); expect(outputLines).toContain("Build complete!"); - expect(errorEvents.length).toBe(1); - expect(errorEvents[0].line).toBe("Warning: deprecated package"); + // Should have at least the hook's stderr message + // (may also have pull-latest notes if fetch/rebase fails, which is expected) + const hookErrorEvent = errorEvents.find((e) => e.line === "Warning: deprecated package"); + expect(hookErrorEvent).toBeDefined(); // Last event should be end with exitCode 0 const finalEvent = initEvents[initEvents.length - 1];