Skip to content

Commit 839034d

Browse files
committed
🤖 feat: add pull latest setting to workspace creation
Add a global setting in Settings > General to fetch and rebase on the latest origin branch when creating new workspaces. Backend: - LocalRuntime: fetch origin/trunk and rebase after worktree creation - SSHRuntime: fetch and rebase after branch checkout during init - Properly abort failed rebases to avoid stuck state Error handling is best-effort - network/rebase failures log warnings but never fail workspace creation. _Generated with `mux`_
1 parent 27c9cd0 commit 839034d

File tree

8 files changed

+178
-4
lines changed

8 files changed

+178
-4
lines changed

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getPendingScopeId,
1414
getProjectScopeId,
1515
getThinkingLevelKey,
16+
PULL_LATEST_ENABLED_KEY,
1617
} from "@/common/constants/storage";
1718
import type { Toast } from "@/browser/components/ChatInputToast";
1819
import { createErrorToast } from "@/browser/components/ChatInputToasts";
@@ -109,12 +110,16 @@ export function useCreationWorkspace({
109110
? parseRuntimeString(runtimeString, "")
110111
: undefined;
111112

113+
// Read global pull latest setting (default true = enabled)
114+
const pullLatest = readPersistedState<boolean>(PULL_LATEST_ENABLED_KEY, true);
115+
112116
// Send message with runtime config and creation-specific params
113117
const result = await window.api.workspace.sendMessage(null, message, {
114118
...sendMessageOptions,
115119
runtimeConfig,
116120
projectPath, // Pass projectPath when workspaceId is null
117121
trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings
122+
pullLatest, // Pass global pull latest preference
118123
imageParts: imageParts && imageParts.length > 0 ? imageParts : undefined,
119124
});
120125

src/browser/components/Settings/sections/GeneralSection.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import React from "react";
22
import { MoonStar, SunMedium } from "lucide-react";
33
import { useTheme } from "@/browser/contexts/ThemeContext";
4+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
5+
import { PULL_LATEST_ENABLED_KEY } from "@/common/constants/storage";
46

57
export function GeneralSection() {
68
const { theme, toggleTheme } = useTheme();
9+
const [pullLatestEnabled, setPullLatestEnabled] = usePersistedState<boolean>(
10+
PULL_LATEST_ENABLED_KEY,
11+
true
12+
);
713

814
return (
915
<div className="space-y-6">
@@ -33,6 +39,24 @@ export function GeneralSection() {
3339
</button>
3440
</div>
3541
</div>
42+
43+
<div>
44+
<h3 className="text-foreground mb-4 text-sm font-medium">Workspace Creation</h3>
45+
<label className="flex cursor-pointer items-center justify-between">
46+
<div>
47+
<div className="text-foreground text-sm">Pull latest on creation</div>
48+
<div className="text-muted text-xs">
49+
Fetch and rebase on the latest origin branch when creating new workspaces
50+
</div>
51+
</div>
52+
<input
53+
type="checkbox"
54+
checked={pullLatestEnabled}
55+
onChange={(e) => setPullLatestEnabled(e.target.checked)}
56+
className="h-4 w-4 cursor-pointer"
57+
/>
58+
</label>
59+
</div>
3660
</div>
3761
);
3862
}

src/common/constants/storage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ export function getTrunkBranchKey(projectPath: string): string {
114114
return `trunkBranch:${projectPath}`;
115115
}
116116

117+
/**
118+
* Get the localStorage key for pull latest preference (global)
119+
* When enabled, new workspaces fetch and rebase on latest origin/<trunk>
120+
* Format: "pullLatestEnabled"
121+
*/
122+
export const PULL_LATEST_ENABLED_KEY = "pullLatestEnabled";
123+
117124
/**
118125
* Get the localStorage key for the preferred compaction model (global)
119126
* Format: "preferredCompactionModel"

src/common/types/ipc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export interface IPCApi {
309309
runtimeConfig?: RuntimeConfig;
310310
projectPath?: string; // Required when workspaceId is null
311311
trunkBranch?: string; // Optional - trunk branch to branch from (when workspaceId is null)
312+
pullLatest?: boolean; // Optional - fetch and rebase on origin/<trunk> (when workspaceId is null)
312313
}
313314
): Promise<
314315
| Result<void, SendMessageError>

src/node/runtime/LocalRuntime.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ export class LocalRuntime implements Runtime {
318318
}
319319

320320
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
321-
const { projectPath, branchName, trunkBranch, initLogger } = params;
321+
const { projectPath, branchName, trunkBranch, initLogger, pullLatest } = params;
322322

323323
try {
324324
// Compute workspace path using the canonical method
@@ -365,6 +365,11 @@ export class LocalRuntime implements Runtime {
365365

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

368+
// Pull latest from origin if requested (best-effort, non-blocking on failure)
369+
if (pullLatest) {
370+
await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger);
371+
}
372+
368373
return { success: true, workspacePath };
369374
} catch (error) {
370375
return {
@@ -374,6 +379,48 @@ export class LocalRuntime implements Runtime {
374379
}
375380
}
376381

382+
/**
383+
* Fetch and rebase on latest origin/<trunkBranch>
384+
* Best-effort operation - logs warnings but doesn't fail workspace creation
385+
*/
386+
private async pullLatestFromOrigin(
387+
workspacePath: string,
388+
trunkBranch: string,
389+
initLogger: InitLogger
390+
): Promise<void> {
391+
try {
392+
initLogger.logStep("Fetching latest from origin...");
393+
394+
// Fetch the trunk branch from origin
395+
using fetchProc = execAsync(`git -C "${workspacePath}" fetch origin "${trunkBranch}"`);
396+
await fetchProc.result;
397+
398+
initLogger.logStep("Rebasing on latest...");
399+
400+
// Attempt rebase on origin/<trunkBranch>
401+
// execAsync throws on non-zero exit, so we catch rebase failures separately
402+
try {
403+
using rebaseProc = execAsync(`git -C "${workspacePath}" rebase "origin/${trunkBranch}"`);
404+
await rebaseProc.result;
405+
initLogger.logStep("Rebased on latest successfully");
406+
} catch (rebaseError) {
407+
// Rebase failed (conflicts or other error) - abort and warn
408+
try {
409+
using abortProc = execAsync(`git -C "${workspacePath}" rebase --abort`);
410+
await abortProc.result;
411+
} catch {
412+
// Abort may fail if rebase wasn't in progress; ignore
413+
}
414+
const errorMsg = getErrorMessage(rebaseError);
415+
initLogger.logStderr(`Warning: Rebase failed (${errorMsg}), continuing with current state`);
416+
}
417+
} catch (error) {
418+
// Non-fatal: log warning and continue (fetch failed)
419+
const errorMsg = getErrorMessage(error);
420+
initLogger.logStderr(`Warning: Failed to pull latest (${errorMsg}), continuing anyway`);
421+
}
422+
}
423+
377424
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
378425
const { projectPath, workspacePath, initLogger } = params;
379426

src/node/runtime/Runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ export interface WorkspaceCreationParams {
123123
initLogger: InitLogger;
124124
/** Optional abort signal for cancellation */
125125
abortSignal?: AbortSignal;
126+
/** Whether to fetch and rebase on origin/<trunkBranch> after creation */
127+
pullLatest?: boolean;
126128
}
127129

128130
/**
@@ -151,6 +153,8 @@ export interface WorkspaceInitParams {
151153
initLogger: InitLogger;
152154
/** Optional abort signal for cancellation */
153155
abortSignal?: AbortSignal;
156+
/** Whether to fetch and rebase on origin/<trunkBranch> after init */
157+
pullLatest?: boolean;
154158
}
155159

156160
/**

src/node/runtime/SSHRuntime.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,15 @@ export class SSHRuntime implements Runtime {
856856
}
857857

858858
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
859-
const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params;
859+
const {
860+
projectPath,
861+
branchName,
862+
trunkBranch,
863+
workspacePath,
864+
initLogger,
865+
abortSignal,
866+
pullLatest,
867+
} = params;
860868

861869
try {
862870
// 1. Sync project to remote (opportunistic rsync with scp fallback)
@@ -906,7 +914,12 @@ export class SSHRuntime implements Runtime {
906914
}
907915
initLogger.logStep("Branch checked out successfully");
908916

909-
// 3. Run .mux/init hook if it exists
917+
// 3. Pull latest from origin if requested (best-effort, non-blocking on failure)
918+
if (pullLatest) {
919+
await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger, abortSignal);
920+
}
921+
922+
// 4. Run .mux/init hook if it exists
910923
// Note: runInitHook calls logComplete() internally if hook exists
911924
const hookExists = await checkInitHookExists(projectPath);
912925
if (hookExists) {
@@ -928,6 +941,71 @@ export class SSHRuntime implements Runtime {
928941
}
929942
}
930943

944+
/**
945+
* Fetch and rebase on latest origin/<trunkBranch> on remote
946+
* Best-effort operation - logs warnings but doesn't fail workspace initialization
947+
*/
948+
private async pullLatestFromOrigin(
949+
workspacePath: string,
950+
trunkBranch: string,
951+
initLogger: InitLogger,
952+
abortSignal?: AbortSignal
953+
): Promise<void> {
954+
try {
955+
initLogger.logStep("Fetching latest from origin...");
956+
957+
// Fetch the trunk branch from origin
958+
const fetchCmd = `git fetch origin ${shescape.quote(trunkBranch)}`;
959+
const fetchStream = await this.exec(fetchCmd, {
960+
cwd: workspacePath,
961+
timeout: 120, // 2 minutes for network operation
962+
abortSignal,
963+
});
964+
965+
const fetchExitCode = await fetchStream.exitCode;
966+
if (fetchExitCode !== 0) {
967+
const fetchStderr = await streamToString(fetchStream.stderr);
968+
initLogger.logStderr(`Warning: Failed to fetch latest (${fetchStderr}), continuing anyway`);
969+
return;
970+
}
971+
972+
initLogger.logStep("Rebasing on latest...");
973+
974+
// Attempt rebase on origin/<trunkBranch>
975+
const rebaseCmd = `git rebase origin/${shescape.quote(trunkBranch)}`;
976+
const rebaseStream = await this.exec(rebaseCmd, {
977+
cwd: workspacePath,
978+
timeout: 300, // 5 minutes for rebase
979+
abortSignal,
980+
});
981+
982+
const [rebaseStderr, rebaseExitCode] = await Promise.all([
983+
streamToString(rebaseStream.stderr),
984+
rebaseStream.exitCode,
985+
]);
986+
987+
if (rebaseExitCode !== 0 || rebaseStderr.includes("CONFLICT")) {
988+
// Rebase has conflicts or failed - abort and warn
989+
const abortCmd = "git rebase --abort";
990+
const abortStream = await this.exec(abortCmd, {
991+
cwd: workspacePath,
992+
timeout: 30,
993+
abortSignal,
994+
});
995+
await abortStream.exitCode;
996+
initLogger.logStderr(
997+
"Warning: Rebase failed due to conflicts, continuing with current state"
998+
);
999+
} else {
1000+
initLogger.logStep("Rebased on latest successfully");
1001+
}
1002+
} catch (error) {
1003+
// Non-fatal: log warning and continue
1004+
const errorMsg = getErrorMessage(error);
1005+
initLogger.logStderr(`Warning: Failed to pull latest (${errorMsg}), continuing anyway`);
1006+
}
1007+
}
1008+
9311009
async renameWorkspace(
9321010
projectPath: string,
9331011
oldName: string,

src/node/services/ipcMain.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ export class IpcMain {
299299
imageParts?: Array<{ url: string; mediaType: string }>;
300300
runtimeConfig?: RuntimeConfig;
301301
trunkBranch?: string;
302+
pullLatest?: boolean;
302303
}
303304
): Promise<
304305
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
@@ -373,7 +374,13 @@ export class IpcMain {
373374
const { branchName: finalBranchName, result: createResult } =
374375
await createWorkspaceWithCollisionRetry(
375376
runtime,
376-
{ projectPath, branchName, trunkBranch: recommendedTrunk, initLogger },
377+
{
378+
projectPath,
379+
branchName,
380+
trunkBranch: recommendedTrunk,
381+
initLogger,
382+
pullLatest: options.pullLatest,
383+
},
377384
branchName
378385
);
379386

@@ -426,6 +433,7 @@ export class IpcMain {
426433
trunkBranch: recommendedTrunk,
427434
workspacePath: createResult.workspacePath,
428435
initLogger,
436+
pullLatest: options.pullLatest,
429437
})
430438
.catch((error: unknown) => {
431439
const errorMsg = error instanceof Error ? error.message : String(error);

0 commit comments

Comments
 (0)