Skip to content

Commit 58a4be5

Browse files
committed
🤖 feat: auto rebase on trunk when creating a new workspace
- Ephemeral checkbox in creation UI - IPC flag only for first sendMessage - Local+SSH runtime fetch+rebase before init hook - LocalRuntime test _Generated with `mux`_
1 parent 7d2f8cc commit 58a4be5

File tree

9 files changed

+242
-1
lines changed

9 files changed

+242
-1
lines changed

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ interface CreationControlsProps {
1010
runtimeMode: RuntimeMode;
1111
sshHost: string;
1212
onRuntimeChange: (mode: RuntimeMode, host: string) => void;
13+
autoRebaseTrunk: boolean;
14+
onAutoRebaseChange: (value: boolean) => void;
1315
disabled: boolean;
1416
}
1517

@@ -74,6 +76,27 @@ export function CreationControls(props: CreationControlsProps) {
7476
</Tooltip>
7577
</TooltipWrapper>
7678
</div>
79+
80+
{/* Auto Rebase Toggle */}
81+
<div className="flex items-center gap-1" data-component="AutoRebaseToggle">
82+
<label className="text-muted flex items-center gap-1 text-xs">
83+
<input
84+
type="checkbox"
85+
checked={props.autoRebaseTrunk}
86+
onChange={(event) => props.onAutoRebaseChange(event.target.checked)}
87+
disabled={props.disabled}
88+
className="accent-accent"
89+
/>
90+
Auto rebase onto origin/{props.trunkBranch || "main"}
91+
</label>
92+
<TooltipWrapper inline>
93+
<span className="text-muted cursor-help text-xs">?</span>
94+
<Tooltip className="tooltip" align="center" width="wide">
95+
Fetches origin/{props.trunkBranch || "main"} and rebases the new workspace before
96+
running any init hooks. Disable if you need to stay on the local trunk snapshot.
97+
</Tooltip>
98+
</TooltipWrapper>
99+
</div>
77100
</div>
78101
);
79102
}

src/browser/components/ChatInput/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10211021
runtimeMode={creationState.runtimeMode}
10221022
sshHost={creationState.sshHost}
10231023
onRuntimeChange={creationState.setRuntimeOptions}
1024+
autoRebaseTrunk={creationState.autoRebaseTrunk}
1025+
onAutoRebaseChange={creationState.setAutoRebaseTrunk}
10241026
disabled={creationState.isSending || isSending}
10251027
/>
10261028
)}

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ interface UseCreationWorkspaceReturn {
3939
runtimeMode: RuntimeMode;
4040
sshHost: string;
4141
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
42+
autoRebaseTrunk: boolean;
43+
setAutoRebaseTrunk: (value: boolean) => void;
4244
error: string | null;
4345
setError: (error: string | null) => void;
4446
isSending: boolean;
@@ -60,6 +62,7 @@ export function useCreationWorkspace({
6062
const [recommendedTrunk, setRecommendedTrunk] = useState<string | null>(null);
6163
const [error, setError] = useState<string | null>(null);
6264
const [isSending, setIsSending] = useState(false);
65+
const [autoRebaseTrunk, setAutoRebaseTrunk] = useState(true);
6366

6467
// Centralized draft workspace settings with automatic persistence
6568
const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } =
@@ -107,6 +110,7 @@ export function useCreationWorkspace({
107110
runtimeConfig,
108111
projectPath, // Pass projectPath when workspaceId is null
109112
trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings
113+
autoRebaseTrunk,
110114
});
111115

112116
if (!result.success) {
@@ -139,6 +143,7 @@ export function useCreationWorkspace({
139143
getRuntimeString,
140144
sendMessageOptions,
141145
settings.trunkBranch,
146+
autoRebaseTrunk,
142147
]
143148
);
144149

@@ -149,6 +154,8 @@ export function useCreationWorkspace({
149154
runtimeMode: settings.runtimeMode,
150155
sshHost: settings.sshHost,
151156
setRuntimeOptions,
157+
autoRebaseTrunk,
158+
setAutoRebaseTrunk,
152159
error,
153160
setError,
154161
isSending,

src/common/types/ipc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ export interface SendMessageOptions {
217217
maxOutputTokens?: number;
218218
providerOptions?: MuxProviderOptions;
219219
mode?: string; // Mode name - frontend narrows to specific values, backend accepts any string
220+
/** Whether to rebase onto origin/<trunk> before first workspace init */
221+
autoRebaseTrunk?: boolean;
220222
muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box
221223
}
222224

src/node/runtime/LocalRuntime.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { describe, expect, it } from "bun:test";
22
import * as os from "os";
33
import * as path from "path";
44
import { LocalRuntime } from "./LocalRuntime";
5+
import * as fs from "fs";
6+
import * as fsPromises from "fs/promises";
7+
import { execFileSync } from "child_process";
8+
import type { InitLogger } from "./Runtime";
59

610
describe("LocalRuntime constructor", () => {
711
it("should expand tilde in srcBaseDir", () => {
@@ -65,3 +69,88 @@ describe("LocalRuntime.resolvePath", () => {
6569
expect(path.isAbsolute(resolved)).toBe(true);
6670
});
6771
});
72+
73+
const GIT_ENV = {
74+
...process.env,
75+
GIT_AUTHOR_NAME: "Test User",
76+
GIT_AUTHOR_EMAIL: "test@example.com",
77+
GIT_COMMITTER_NAME: "Test User",
78+
GIT_COMMITTER_EMAIL: "test@example.com",
79+
};
80+
81+
function runGit(args: string[], cwd?: string) {
82+
execFileSync("git", args, { cwd, env: GIT_ENV });
83+
}
84+
85+
function gitOutput(args: string[], cwd?: string): string {
86+
return execFileSync("git", args, { cwd, env: GIT_ENV }).toString().trim();
87+
}
88+
89+
function createTestInitLogger(): InitLogger {
90+
return {
91+
logStep: () => {},
92+
logStdout: () => {},
93+
logStderr: () => {},
94+
logComplete: () => {},
95+
};
96+
}
97+
98+
describe("LocalRuntime auto rebase", () => {
99+
it("rebases onto origin when enabled", async () => {
100+
const tmpRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "local-runtime-"));
101+
const originDir = path.join(tmpRoot, "origin.git");
102+
const projectDir = path.join(tmpRoot, "project");
103+
const upstreamDir = path.join(tmpRoot, "upstream");
104+
const workspacesDir = path.join(tmpRoot, "workspaces");
105+
const trunkBranch = "main";
106+
107+
try {
108+
runGit(["init", "--bare", originDir]);
109+
110+
fs.mkdirSync(projectDir, { recursive: true });
111+
runGit(["init", "-b", trunkBranch], projectDir);
112+
runGit(["remote", "add", "origin", originDir], projectDir);
113+
114+
fs.writeFileSync(path.join(projectDir, "README.md"), "first\n");
115+
runGit(["add", "README.md"], projectDir);
116+
runGit(["commit", "-m", "initial"], projectDir);
117+
runGit(["push", "-u", "origin", trunkBranch], projectDir);
118+
119+
runGit(["clone", "-b", trunkBranch, originDir, upstreamDir]);
120+
fs.appendFileSync(path.join(upstreamDir, "README.md"), "second\n");
121+
runGit(["commit", "-am", "upstream change"], upstreamDir);
122+
runGit(["push", "origin", trunkBranch], upstreamDir);
123+
124+
const runtime = new LocalRuntime(workspacesDir);
125+
const initLogger = createTestInitLogger();
126+
const branchName = "auto-rebase-test";
127+
128+
const createResult = await runtime.createWorkspace({
129+
projectPath: projectDir,
130+
branchName,
131+
trunkBranch,
132+
directoryName: branchName,
133+
initLogger,
134+
});
135+
136+
expect(createResult.success).toBe(true);
137+
expect(createResult.workspacePath).toBeTruthy();
138+
const workspacePath = createResult.workspacePath!;
139+
140+
await runtime.initWorkspace({
141+
projectPath: projectDir,
142+
branchName,
143+
trunkBranch,
144+
workspacePath,
145+
initLogger,
146+
autoRebaseTrunk: true,
147+
});
148+
149+
const workspaceHead = gitOutput(["rev-parse", "HEAD"], workspacePath);
150+
const originHead = gitOutput(["rev-parse", `origin/${trunkBranch}`], projectDir);
151+
expect(workspaceHead).toBe(originHead);
152+
} finally {
153+
await fsPromises.rm(tmpRoot, { recursive: true, force: true });
154+
}
155+
});
156+
});

src/node/runtime/LocalRuntime.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,13 @@ export class LocalRuntime implements Runtime {
375375
}
376376

377377
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
378-
const { projectPath, workspacePath, initLogger } = params;
378+
const { projectPath, workspacePath, initLogger, trunkBranch, autoRebaseTrunk } = params;
379379

380380
try {
381+
if (autoRebaseTrunk && trunkBranch) {
382+
await this.runAutoRebase(workspacePath, trunkBranch, initLogger);
383+
}
384+
381385
// Run .mux/init hook if it exists
382386
// Note: runInitHook calls logComplete() internally if hook exists
383387
const hookExists = await checkInitHookExists(projectPath);
@@ -399,6 +403,46 @@ export class LocalRuntime implements Runtime {
399403
}
400404
}
401405

406+
private async runAutoRebase(
407+
workspacePath: string,
408+
trunkBranch: string,
409+
initLogger: InitLogger
410+
): Promise<void> {
411+
const quote = (value: string) => `"${value}"`;
412+
413+
const hasOrigin = await (async () => {
414+
try {
415+
using remoteCheck = execAsync(`git -C ${quote(workspacePath)} remote get-url origin`);
416+
const { stdout } = await remoteCheck.result;
417+
return stdout.trim().length > 0;
418+
} catch {
419+
return false;
420+
}
421+
})();
422+
423+
if (!hasOrigin) {
424+
initLogger.logStep("Skipping auto-rebase: origin remote not configured.");
425+
return;
426+
}
427+
428+
initLogger.logStep(`Fetching origin/${trunkBranch}...`);
429+
try {
430+
using fetchProc = execAsync(`git -C ${quote(workspacePath)} fetch origin ${trunkBranch}`);
431+
await fetchProc.result;
432+
} catch (error) {
433+
throw new Error(`Failed to fetch origin/${trunkBranch}: ${getErrorMessage(error)}`);
434+
}
435+
436+
initLogger.logStep(`Rebasing onto origin/${trunkBranch}...`);
437+
try {
438+
using rebaseProc = execAsync(`git -C ${quote(workspacePath)} rebase origin/${trunkBranch}`);
439+
await rebaseProc.result;
440+
initLogger.logStep(`Rebased onto origin/${trunkBranch}`);
441+
} catch (error) {
442+
throw new Error(`Failed to rebase onto origin/${trunkBranch}: ${getErrorMessage(error)}`);
443+
}
444+
}
445+
402446
/**
403447
* Run .mux/init hook if it exists and is executable
404448
*/

src/node/runtime/Runtime.ts

Lines changed: 2 additions & 0 deletions
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+
/** Automatically fetch/rebase onto origin/<trunkBranch> before running init hooks */
155+
autoRebaseTrunk?: boolean;
154156
}
155157

156158
/**

src/node/runtime/SSHRuntime.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,72 @@ export class SSHRuntime implements Runtime {
722722
}
723723
}
724724

725+
private async runAutoRebase(
726+
workspacePath: string,
727+
trunkBranch: string,
728+
initLogger: InitLogger,
729+
abortSignal?: AbortSignal
730+
): Promise<void> {
731+
const remoteCheck = await this.exec(`git remote get-url origin`, {
732+
cwd: workspacePath,
733+
timeout: 30,
734+
abortSignal,
735+
});
736+
const [, _remoteStderr, remoteExitCode] = await Promise.all([
737+
streamToString(remoteCheck.stdout),
738+
streamToString(remoteCheck.stderr),
739+
remoteCheck.exitCode,
740+
]);
741+
742+
if (remoteExitCode !== 0) {
743+
initLogger.logStep("Skipping auto-rebase: origin remote not configured.");
744+
return;
745+
}
746+
747+
await this.runGitCommandWithLogging(
748+
workspacePath,
749+
`git fetch origin ${trunkBranch}`,
750+
`Fetching origin/${trunkBranch}...`,
751+
initLogger,
752+
abortSignal
753+
);
754+
755+
await this.runGitCommandWithLogging(
756+
workspacePath,
757+
`git rebase origin/${trunkBranch}`,
758+
`Rebasing onto origin/${trunkBranch}...`,
759+
initLogger,
760+
abortSignal
761+
);
762+
763+
initLogger.logStep(`Rebased onto origin/${trunkBranch}`);
764+
}
765+
766+
private async runGitCommandWithLogging(
767+
workspacePath: string,
768+
command: string,
769+
description: string,
770+
initLogger: InitLogger,
771+
abortSignal?: AbortSignal,
772+
timeout = 300
773+
): Promise<void> {
774+
initLogger.logStep(description);
775+
const stream = await this.exec(command, {
776+
cwd: workspacePath,
777+
timeout,
778+
abortSignal,
779+
});
780+
const [stdout, stderr, exitCode] = await Promise.all([
781+
streamToString(stream.stdout),
782+
streamToString(stream.stderr),
783+
stream.exitCode,
784+
]);
785+
786+
if (exitCode !== 0) {
787+
throw new Error(stderr.trim() || stdout.trim() || description);
788+
}
789+
}
790+
725791
/**
726792
* Run .mux/init hook on remote machine if it exists
727793
*/
@@ -906,6 +972,10 @@ export class SSHRuntime implements Runtime {
906972
}
907973
initLogger.logStep("Branch checked out successfully");
908974

975+
if (params.autoRebaseTrunk && trunkBranch) {
976+
await this.runAutoRebase(workspacePath, trunkBranch, initLogger, abortSignal);
977+
}
978+
909979
// 3. Run .mux/init hook if it exists
910980
// Note: runInitHook calls logComplete() internally if hook exists
911981
const hookExists = await checkInitHookExists(projectPath);

src/node/services/ipcMain.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ export class IpcMain {
267267
trunkBranch: recommendedTrunk,
268268
workspacePath: createResult.workspacePath,
269269
initLogger,
270+
autoRebaseTrunk: options.autoRebaseTrunk ?? false,
270271
})
271272
.catch((error: unknown) => {
272273
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -560,6 +561,7 @@ export class IpcMain {
560561
trunkBranch: normalizedTrunkBranch,
561562
workspacePath: createResult.workspacePath,
562563
initLogger,
564+
autoRebaseTrunk: false,
563565
})
564566
.catch((error: unknown) => {
565567
const errorMsg = error instanceof Error ? error.message : String(error);

0 commit comments

Comments
 (0)