Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface CreationControlsProps {
runtimeMode: RuntimeMode;
sshHost: string;
onRuntimeChange: (mode: RuntimeMode, host: string) => void;
autoRebaseTrunk: boolean;
onAutoRebaseChange: (value: boolean) => void;
disabled: boolean;
}

Expand Down Expand Up @@ -74,6 +76,27 @@ export function CreationControls(props: CreationControlsProps) {
</Tooltip>
</TooltipWrapper>
</div>

{/* Auto Rebase Toggle */}
<div className="flex items-center gap-1" data-component="AutoRebaseToggle">
<label className="text-muted flex items-center gap-1 text-xs">
<input
type="checkbox"
checked={props.autoRebaseTrunk}
onChange={(event) => props.onAutoRebaseChange(event.target.checked)}
disabled={props.disabled}
className="accent-accent"
/>
Auto rebase onto origin/{props.trunkBranch || "main"}
</label>
<TooltipWrapper inline>
<span className="text-muted cursor-help text-xs">?</span>
<Tooltip className="tooltip" align="center" width="wide">
Fetches origin/{props.trunkBranch || "main"} and rebases the new workspace before
running any init hooks. Disable if you need to stay on the local trunk snapshot.
</Tooltip>
</TooltipWrapper>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
runtimeMode={creationState.runtimeMode}
sshHost={creationState.sshHost}
onRuntimeChange={creationState.setRuntimeOptions}
autoRebaseTrunk={creationState.autoRebaseTrunk}
onAutoRebaseChange={creationState.setAutoRebaseTrunk}
disabled={creationState.isSending || isSending}
/>
)}
Expand Down
7 changes: 7 additions & 0 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ interface UseCreationWorkspaceReturn {
runtimeMode: RuntimeMode;
sshHost: string;
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
autoRebaseTrunk: boolean;
setAutoRebaseTrunk: (value: boolean) => void;
error: string | null;
setError: (error: string | null) => void;
isSending: boolean;
Expand All @@ -60,6 +62,7 @@ export function useCreationWorkspace({
const [recommendedTrunk, setRecommendedTrunk] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [autoRebaseTrunk, setAutoRebaseTrunk] = useState(true);

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

if (!result.success) {
Expand Down Expand Up @@ -139,6 +143,7 @@ export function useCreationWorkspace({
getRuntimeString,
sendMessageOptions,
settings.trunkBranch,
autoRebaseTrunk,
]
);

Expand All @@ -149,6 +154,8 @@ export function useCreationWorkspace({
runtimeMode: settings.runtimeMode,
sshHost: settings.sshHost,
setRuntimeOptions,
autoRebaseTrunk,
setAutoRebaseTrunk,
error,
setError,
isSending,
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export interface SendMessageOptions {
maxOutputTokens?: number;
providerOptions?: MuxProviderOptions;
mode?: string; // Mode name - frontend narrows to specific values, backend accepts any string
/** Whether to rebase onto origin/<trunk> before first workspace init */
autoRebaseTrunk?: boolean;
muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box
}

Expand Down
97 changes: 97 additions & 0 deletions src/node/runtime/LocalRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { describe, expect, it } from "bun:test";
import * as os from "os";
import * as path from "path";
import { LocalRuntime } from "./LocalRuntime";
import * as fsPromises from "fs/promises";
import { execFileSync } from "child_process";
import type { InitLogger } from "./Runtime";

describe("LocalRuntime constructor", () => {
it("should expand tilde in srcBaseDir", () => {
Expand Down Expand Up @@ -65,3 +68,97 @@ describe("LocalRuntime.resolvePath", () => {
expect(path.isAbsolute(resolved)).toBe(true);
});
});

const GIT_ENV = {
...process.env,
GIT_AUTHOR_NAME: "Test User",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test User",
GIT_COMMITTER_EMAIL: "test@example.com",
};

function runGit(args: string[], cwd?: string) {
execFileSync("git", args, { cwd, env: GIT_ENV });
}

function gitOutput(args: string[], cwd?: string): string {
return execFileSync("git", args, { cwd, env: GIT_ENV }).toString().trim();
}

function createTestInitLogger(): InitLogger {
const logs: string[] = [];
return {
logStep: (m: string) => {
logs.push(`[step] ${m}`);
},
logStdout: (line: string) => {
if (line) logs.push(`[out] ${line}`);
},
logStderr: (line: string) => {
if (line) logs.push(`[err] ${line}`);
},
logComplete: (code: number) => {
logs.push(`[done] ${code}`);
},
};
}

describe("LocalRuntime auto rebase", () => {
it("rebases onto origin when enabled", async () => {
const tmpRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "local-runtime-"));
const originDir = path.join(tmpRoot, "origin.git");
const projectDir = path.join(tmpRoot, "project");
const upstreamDir = path.join(tmpRoot, "upstream");
const workspacesDir = path.join(tmpRoot, "workspaces");
const trunkBranch = "main";

try {
runGit(["init", "--bare", originDir]);

await fsPromises.mkdir(projectDir, { recursive: true });
runGit(["init", "-b", trunkBranch], projectDir);
runGit(["remote", "add", "origin", originDir], projectDir);

await fsPromises.writeFile(path.join(projectDir, "README.md"), "first\n");
runGit(["add", "README.md"], projectDir);
runGit(["commit", "-m", "initial"], projectDir);
runGit(["push", "-u", "origin", trunkBranch], projectDir);

runGit(["clone", "-b", trunkBranch, originDir, upstreamDir]);
await fsPromises.appendFile(path.join(upstreamDir, "README.md"), "second\n");
runGit(["commit", "-am", "upstream change"], upstreamDir);
runGit(["push", "origin", trunkBranch], upstreamDir);

const runtime = new LocalRuntime(workspacesDir);
const initLogger = createTestInitLogger();
const branchName = "auto-rebase-test";

const createResult = await runtime.createWorkspace({
projectPath: projectDir,
branchName,
trunkBranch,
directoryName: branchName,
initLogger,
});

expect(createResult.success).toBe(true);
expect(createResult.workspacePath).toBeTruthy();
const workspacePath = createResult.workspacePath!;

await runtime.initWorkspace({
projectPath: projectDir,
branchName,
trunkBranch,
workspacePath,
initLogger,
autoRebaseTrunk: true,
});

const workspaceHead = gitOutput(["rev-parse", "HEAD"], workspacePath);
const originHead = gitOutput(["rev-parse", `origin/${trunkBranch}`], projectDir);
expect(workspaceHead).toBe(originHead);
} finally {
await fsPromises.rm(tmpRoot, { recursive: true, force: true });
}
});
});
46 changes: 45 additions & 1 deletion src/node/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,13 @@ export class LocalRuntime implements Runtime {
}

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

try {
if (autoRebaseTrunk && trunkBranch) {
await this.runAutoRebase(workspacePath, trunkBranch, initLogger);
}

// Run .mux/init hook if it exists
// Note: runInitHook calls logComplete() internally if hook exists
const hookExists = await checkInitHookExists(projectPath);
Expand All @@ -399,6 +403,46 @@ export class LocalRuntime implements Runtime {
}
}

private async runAutoRebase(
workspacePath: string,
trunkBranch: string,
initLogger: InitLogger
): Promise<void> {
const quote = (value: string) => `"${value}"`;

const hasOrigin = await (async () => {
try {
using remoteCheck = execAsync(`git -C ${quote(workspacePath)} remote get-url origin`);
const { stdout } = await remoteCheck.result;
return stdout.trim().length > 0;
} catch {
return false;
}
})();

if (!hasOrigin) {
initLogger.logStep("Skipping auto-rebase: origin remote not configured.");
return;
}

initLogger.logStep(`Fetching origin/${trunkBranch}...`);
try {
using fetchProc = execAsync(`git -C ${quote(workspacePath)} fetch origin ${trunkBranch}`);
await fetchProc.result;
} catch (error) {
throw new Error(`Failed to fetch origin/${trunkBranch}: ${getErrorMessage(error)}`);
}

initLogger.logStep(`Rebasing onto origin/${trunkBranch}...`);
try {
using rebaseProc = execAsync(`git -C ${quote(workspacePath)} rebase origin/${trunkBranch}`);
await rebaseProc.result;
initLogger.logStep(`Rebased onto origin/${trunkBranch}`);
} catch (error) {
throw new Error(`Failed to rebase onto origin/${trunkBranch}: ${getErrorMessage(error)}`);
}
}

/**
* Run .mux/init hook if it exists and is executable
*/
Expand Down
2 changes: 2 additions & 0 deletions src/node/runtime/Runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export interface WorkspaceInitParams {
initLogger: InitLogger;
/** Optional abort signal for cancellation */
abortSignal?: AbortSignal;
/** Automatically fetch/rebase onto origin/<trunkBranch> before running init hooks */
autoRebaseTrunk?: boolean;
}

/**
Expand Down
70 changes: 70 additions & 0 deletions src/node/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,72 @@ export class SSHRuntime implements Runtime {
}
}

private async runAutoRebase(
workspacePath: string,
trunkBranch: string,
initLogger: InitLogger,
abortSignal?: AbortSignal
): Promise<void> {
const remoteCheck = await this.exec(`git remote get-url origin`, {
cwd: workspacePath,
timeout: 30,
abortSignal,
});
const [, _remoteStderr, remoteExitCode] = await Promise.all([
streamToString(remoteCheck.stdout),
streamToString(remoteCheck.stderr),
remoteCheck.exitCode,
]);

if (remoteExitCode !== 0) {
initLogger.logStep("Skipping auto-rebase: origin remote not configured.");
return;
}

await this.runGitCommandWithLogging(
workspacePath,
`git fetch origin ${trunkBranch}`,
`Fetching origin/${trunkBranch}...`,
initLogger,
abortSignal
);

await this.runGitCommandWithLogging(
workspacePath,
`git rebase origin/${trunkBranch}`,
`Rebasing onto origin/${trunkBranch}...`,
initLogger,
abortSignal
);

initLogger.logStep(`Rebased onto origin/${trunkBranch}`);
}

private async runGitCommandWithLogging(
workspacePath: string,
command: string,
description: string,
initLogger: InitLogger,
abortSignal?: AbortSignal,
timeout = 300
): Promise<void> {
initLogger.logStep(description);
const stream = await this.exec(command, {
cwd: workspacePath,
timeout,
abortSignal,
});
const [stdout, stderr, exitCode] = await Promise.all([
streamToString(stream.stdout),
streamToString(stream.stderr),
stream.exitCode,
]);

if (exitCode !== 0) {
throw new Error(stderr.trim() || stdout.trim() || description);
}
}

/**
* Run .mux/init hook on remote machine if it exists
*/
Expand Down Expand Up @@ -906,6 +972,10 @@ export class SSHRuntime implements Runtime {
}
initLogger.logStep("Branch checked out successfully");

if (params.autoRebaseTrunk && trunkBranch) {
await this.runAutoRebase(workspacePath, trunkBranch, initLogger, abortSignal);
}

// 3. Run .mux/init hook if it exists
// Note: runInitHook calls logComplete() internally if hook exists
const hookExists = await checkInitHookExists(projectPath);
Expand Down
2 changes: 2 additions & 0 deletions src/node/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export class IpcMain {
trunkBranch: recommendedTrunk,
workspacePath: createResult.workspacePath,
initLogger,
autoRebaseTrunk: options.autoRebaseTrunk ?? false,
})
.catch((error: unknown) => {
const errorMsg = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -560,6 +561,7 @@ export class IpcMain {
trunkBranch: normalizedTrunkBranch,
workspacePath: createResult.workspacePath,
initLogger,
autoRebaseTrunk: false,
})
.catch((error: unknown) => {
const errorMsg = error instanceof Error ? error.message : String(error);
Expand Down