diff --git a/src/browser/api.ts b/src/browser/api.ts index 449e57e23..64cef7b35 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -211,8 +211,15 @@ const webApi: IPCApi = { }, workspace: { list: () => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST), - create: (projectPath, branchName, trunkBranch) => - invokeIPC(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch), + create: (projectPath, branchName, trunkBranch, runtimeConfig, options) => + invokeIPC( + IPC_CHANNELS.WORKSPACE_CREATE, + projectPath, + branchName, + trunkBranch, + runtimeConfig, + options + ), remove: (workspaceId, options) => invokeIPC(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options), rename: (workspaceId, newName) => diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index dd231843b..d1443e977 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -7,6 +7,8 @@ interface CreationControlsProps { branches: string[]; trunkBranch: string; onTrunkBranchChange: (branch: string) => void; + fetchLatest: boolean; + onFetchLatestChange: (value: boolean) => void; runtimeMode: RuntimeMode; sshHost: string; onRuntimeChange: (mode: RuntimeMode, host: string) => void; @@ -74,6 +76,30 @@ export function CreationControls(props: CreationControlsProps) { + + {/* Fetch latest option */} +
+ + + ? + + Fetch latest: +
+ Runs git fetch origin --prune before branching so new workspaces start from + the freshest remote state. +
+
+
); } diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 2dd851af9..e76bdbed3 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -993,6 +993,8 @@ export const ChatInput: React.FC = (props) => { branches={creationState.branches} trunkBranch={creationState.trunkBranch} onTrunkBranchChange={creationState.setTrunkBranch} + fetchLatest={creationState.fetchLatest} + onFetchLatestChange={creationState.setFetchLatest} runtimeMode={creationState.runtimeMode} sshHost={creationState.sshHost} onRuntimeChange={creationState.setRuntimeOptions} diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 89c0ba4ac..234a9f425 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -36,6 +36,8 @@ interface UseCreationWorkspaceReturn { branches: string[]; trunkBranch: string; setTrunkBranch: (branch: string) => void; + fetchLatest: boolean; + setFetchLatest: (value: boolean) => void; runtimeMode: RuntimeMode; sshHost: string; setRuntimeOptions: (mode: RuntimeMode, host: string) => void; @@ -62,7 +64,7 @@ export function useCreationWorkspace({ const [isSending, setIsSending] = useState(false); // Centralized draft workspace settings with automatic persistence - const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } = + const { settings, setRuntimeOptions, setTrunkBranch, setFetchLatest, getRuntimeString } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); // Get send options from shared hook (uses project-scoped storage key) @@ -107,6 +109,7 @@ export function useCreationWorkspace({ runtimeConfig, projectPath, // Pass projectPath when workspaceId is null trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings + fetchLatest: settings.fetchLatest, // Fetch remote updates before branching when requested }); if (!result.success) { @@ -139,6 +142,7 @@ export function useCreationWorkspace({ getRuntimeString, sendMessageOptions, settings.trunkBranch, + settings.fetchLatest, ] ); @@ -146,6 +150,8 @@ export function useCreationWorkspace({ branches, trunkBranch: settings.trunkBranch, setTrunkBranch, + fetchLatest: settings.fetchLatest, + setFetchLatest, runtimeMode: settings.runtimeMode, sshHost: settings.sshHost, setRuntimeOptions, diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 119f12817..e605f9dd5 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -163,7 +163,13 @@ describe("WorkspaceContext", () => { result = await ctx().createWorkspace("/gamma", "feature", "main"); }); - expect(workspaceApi.create).toHaveBeenCalledWith("/gamma", "feature", "main", undefined); + expect(workspaceApi.create).toHaveBeenCalledWith( + "/gamma", + "feature", + "main", + undefined, + undefined + ); expect(projectsApi.list).toHaveBeenCalled(); expect(result!.workspaceId).toBe("ws-new"); expect(result!.projectPath).toBe("/gamma"); diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 2289fe5c0..2e18351d9 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -222,7 +222,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { projectPath: string, branchName: string, trunkBranch: string, - runtimeConfig?: RuntimeConfig + runtimeConfig?: RuntimeConfig, + options?: { fetchLatest?: boolean } ) => { console.assert( typeof trunkBranch === "string" && trunkBranch.trim().length > 0, @@ -232,7 +233,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { projectPath, branchName, trunkBranch, - runtimeConfig + runtimeConfig, + options ); if (result.success) { // Backend has already updated the config - reload projects to get updated state diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 8908a7df4..747ad679e 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -13,6 +13,7 @@ import { getModelKey, getRuntimeKey, getTrunkBranchKey, + getFetchLatestKey, getProjectScopeId, } from "@/common/constants/storage"; import type { UIMode } from "@/common/types/mode"; @@ -33,6 +34,7 @@ export interface DraftWorkspaceSettings { runtimeMode: RuntimeMode; sshHost: string; trunkBranch: string; + fetchLatest: boolean; } /** @@ -52,6 +54,7 @@ export function useDraftWorkspaceSettings( settings: DraftWorkspaceSettings; setRuntimeOptions: (mode: RuntimeMode, host: string) => void; setTrunkBranch: (branch: string) => void; + setFetchLatest: (value: boolean) => void; getRuntimeString: () => string | undefined; } { // Global AI settings (read-only from global state) @@ -74,7 +77,13 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Project-scoped trunk branch preference (persisted per project) + // Project-scoped workspace creation preferences (persisted per project) + const [fetchLatest, setFetchLatest] = usePersistedState( + getFetchLatestKey(projectPath), + false, + { listener: true } + ); + const [trunkBranch, setTrunkBranch] = usePersistedState( getTrunkBranchKey(projectPath), "", @@ -112,9 +121,11 @@ export function useDraftWorkspaceSettings( runtimeMode, sshHost, trunkBranch, + fetchLatest, }, setRuntimeOptions, setTrunkBranch, + setFetchLatest, getRuntimeString, }; } diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 39f63800b..c9693311f 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -71,6 +71,7 @@ export interface CreateWorkspaceOptions { runtime?: string; startMessage?: string; sendMessageOptions?: SendMessageOptions; + fetchLatest?: boolean; } export interface CreateWorkspaceResult { @@ -107,12 +108,15 @@ export async function createNewWorkspace( // Parse runtime config if provided const runtimeConfig = parseRuntimeString(effectiveRuntime, options.workspaceName); + const creationOptions = + options.fetchLatest !== undefined ? { fetchLatest: options.fetchLatest } : undefined; const result = await window.api.workspace.create( options.projectPath, options.workspaceName, effectiveTrunk, - runtimeConfig + runtimeConfig, + creationOptions ); if (!result.success) { diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 5a2b2f121..9f5de6233 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -108,6 +108,15 @@ export function getTrunkBranchKey(projectPath: string): string { return `trunkBranch:${projectPath}`; } +/** + * Get the localStorage key for the fetch-latest preference for a project + * Stores the last fetch option state when creating a workspace + * Format: "fetchLatest:{projectPath}" + */ +export function getFetchLatestKey(projectPath: string): string { + return `fetchLatest:${projectPath}`; +} + /** * Get the localStorage key for the 1M context preference (global) * Format: "use1MContext" diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts index 07824b772..c64f43c6f 100644 --- a/src/common/types/ipc.ts +++ b/src/common/types/ipc.ts @@ -188,6 +188,7 @@ export interface SendMessageOptions { maxOutputTokens?: number; providerOptions?: MuxProviderOptions; mode?: string; // Mode name - frontend narrows to specific values, backend accepts any string + fetchLatest?: boolean; muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box } @@ -233,7 +234,8 @@ export interface IPCApi { projectPath: string, branchName: string, trunkBranch: string, - runtimeConfig?: RuntimeConfig + runtimeConfig?: RuntimeConfig, + options?: { fetchLatest?: boolean } ): Promise< { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string } >; @@ -260,6 +262,7 @@ export interface IPCApi { runtimeConfig?: RuntimeConfig; projectPath?: string; // Required when workspaceId is null trunkBranch?: string; // Optional - trunk branch to branch from (when workspaceId is null) + fetchLatest?: boolean; // Optional - fetch origin before branching (when workspaceId is null) } ): Promise< | Result diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 61807a03f..941db1ae4 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -54,13 +54,14 @@ const api: IPCApi = { }, workspace: { list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST), - create: (projectPath, branchName, trunkBranch: string, runtimeConfig?) => + create: (projectPath, branchName, trunkBranch: string, runtimeConfig?, options?) => ipcRenderer.invoke( IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch, - runtimeConfig + runtimeConfig, + options ), remove: (workspaceId: string, options?: { force?: boolean }) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options), diff --git a/src/node/runtime/LocalRuntime.ts b/src/node/runtime/LocalRuntime.ts index 81012cd12..178cc4a43 100644 --- a/src/node/runtime/LocalRuntime.ts +++ b/src/node/runtime/LocalRuntime.ts @@ -318,7 +318,7 @@ export class LocalRuntime implements Runtime { } async createWorkspace(params: WorkspaceCreationParams): Promise { - const { projectPath, branchName, trunkBranch, initLogger } = params; + const { projectPath, branchName, trunkBranch, initLogger, fetchLatest = false } = params; try { // Compute workspace path using the canonical method @@ -344,6 +344,34 @@ export class LocalRuntime implements Runtime { // Workspace doesn't exist, proceed with creation } + if (fetchLatest) { + initLogger.logStep("Fetching latest from origin..."); + try { + using proc = execAsync(`git -C "${projectPath}" fetch origin --prune`); + await proc.result; + initLogger.logStep("Origin fetch complete"); + } catch (error) { + const errorMessage = getErrorMessage(error); + return { + success: false, + error: `Failed to fetch origin: ${errorMessage}`, + }; + } + } + + let baseRef = trunkBranch; + if (fetchLatest) { + try { + using proc = execAsync( + `git -C "${projectPath}" rev-parse --verify "origin/${trunkBranch}"` + ); + await proc.result; + baseRef = `origin/${trunkBranch}`; + } catch { + // Remote branch missing - fall back to local trunk branch + } + } + // Check if branch exists locally const localBranches = await listLocalBranches(projectPath); const branchExists = localBranches.includes(branchName); @@ -358,7 +386,7 @@ export class LocalRuntime implements Runtime { } else { // Branch doesn't exist, create it from trunk using proc = execAsync( - `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${trunkBranch}"` + `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${baseRef}"` ); await proc.result; } diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 4e01a0ceb..243be69c5 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -121,6 +121,8 @@ export interface WorkspaceCreationParams { directoryName: string; /** Logger for streaming creation progress and init hook output */ initLogger: InitLogger; + /** Whether to fetch remote updates before branching */ + fetchLatest?: boolean; /** Optional abort signal for cancellation */ abortSignal?: AbortSignal; } @@ -149,6 +151,8 @@ export interface WorkspaceInitParams { workspacePath: string; /** Logger for streaming initialization progress and output */ initLogger: InitLogger; + /** Whether to fetch remote updates before branching */ + fetchLatest?: boolean; /** Optional abort signal for cancellation */ abortSignal?: AbortSignal; } diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 22588e873..841fe83d4 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -547,14 +547,79 @@ export class SSHRuntime implements Runtime { private async syncProjectToRemote( projectPath: string, workspacePath: string, + trunkBranch: string, initLogger: InitLogger, - abortSignal?: AbortSignal + abortSignal?: AbortSignal, + fetchLatest = false ): Promise { // Short-circuit if already aborted if (abortSignal?.aborted) { throw new Error("Sync operation aborted before starting"); } + if (fetchLatest) { + initLogger.logStep("Fetching latest from origin..."); + try { + using proc = execAsync(`cd ${shescape.quote(projectPath)} && git fetch origin --prune`); + await proc.result; + initLogger.logStep("Origin fetch complete"); + + try { + using remoteRevProc = execAsync( + `git -C "${projectPath}" rev-parse --verify "refs/remotes/origin/${trunkBranch}"` + ); + const { stdout: remoteStdout } = await remoteRevProc.result; + const remoteRef = remoteStdout.trim(); + + let localRef: string | null = null; + try { + using localRevProc = execAsync( + `git -C "${projectPath}" rev-parse --verify "refs/heads/${trunkBranch}"` + ); + const { stdout: localStdout } = await localRevProc.result; + localRef = localStdout.trim(); + } catch { + // Local branch missing - we'll create it from the remote ref below + } + + let canUpdate = true; + if (localRef) { + if (localRef === remoteRef) { + canUpdate = false; // Already up to date + } else { + try { + using ffCheckProc = execAsync( + `git -C "${projectPath}" merge-base --is-ancestor ${localRef} ${remoteRef}` + ); + await ffCheckProc.result; + } catch { + canUpdate = false; + initLogger.logStderr( + `Skipping fast-forward of ${trunkBranch}: local branch has diverged from origin/${trunkBranch}` + ); + } + } + } + + if (canUpdate) { + using updateRefProc = execAsync( + `git -C "${projectPath}" update-ref refs/heads/${trunkBranch} ${remoteRef}` + ); + await updateRefProc.result; + initLogger.logStep(`Fast-forwarded ${trunkBranch} to origin/${trunkBranch}`); + } + } catch (error) { + initLogger.logStderr( + `Unable to fast-forward ${trunkBranch} to origin/${trunkBranch}: ${getErrorMessage(error)}` + ); + } + } catch (error) { + const errorMsg = getErrorMessage(error); + initLogger.logStderr(`Failed to fetch origin: ${errorMsg}`); + throw new Error(`Failed to fetch origin: ${errorMsg}`); + } + } + // Use timestamp-based bundle path to avoid conflicts (simpler than $$) const timestamp = Date.now(); const bundleTempPath = `~/.mux-bundle-${timestamp}.bundle`; @@ -856,13 +921,28 @@ export class SSHRuntime implements Runtime { } async initWorkspace(params: WorkspaceInitParams): Promise { - const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params; + const { + projectPath, + branchName, + trunkBranch, + workspacePath, + initLogger, + abortSignal, + fetchLatest = false, + } = params; try { // 1. Sync project to remote (opportunistic rsync with scp fallback) initLogger.logStep("Syncing project files to remote..."); try { - await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal); + await this.syncProjectToRemote( + projectPath, + workspacePath, + trunkBranch, + initLogger, + abortSignal, + fetchLatest + ); } catch (error) { const errorMsg = getErrorMessage(error); initLogger.logStderr(`Failed to sync project: ${errorMsg}`); diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 7e711a82a..3225f0c99 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -219,6 +219,7 @@ export class IpcMain { trunkBranch: recommendedTrunk, directoryName: branchName, initLogger, + fetchLatest: options.fetchLatest ?? false, }); if (!createResult.success || !createResult.workspacePath) { @@ -267,6 +268,7 @@ export class IpcMain { trunkBranch: recommendedTrunk, workspacePath: createResult.workspacePath, initLogger, + fetchLatest: options.fetchLatest ?? false, }) .catch((error: unknown) => { const errorMsg = error instanceof Error ? error.message : String(error); @@ -435,7 +437,8 @@ export class IpcMain { projectPath: string, branchName: string, trunkBranch: string, - runtimeConfig?: RuntimeConfig + runtimeConfig?: RuntimeConfig, + options?: { fetchLatest?: boolean } ) => { // Validate workspace name const validation = validateWorkspaceName(branchName); @@ -499,6 +502,7 @@ export class IpcMain { trunkBranch: normalizedTrunkBranch, directoryName: branchName, // Use branch name as directory name initLogger, + fetchLatest: options?.fetchLatest ?? false, }); if (!createResult.success || !createResult.workspacePath) { @@ -560,6 +564,7 @@ export class IpcMain { trunkBranch: normalizedTrunkBranch, workspacePath: createResult.workspacePath, initLogger, + fetchLatest: options?.fetchLatest ?? false, }) .catch((error: unknown) => { const errorMsg = error instanceof Error ? error.message : String(error); diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index 9d20e3d72..c90390bfe 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -1,3 +1,4 @@ +import * as os from "os"; /** * Integration tests for WORKSPACE_CREATE IPC handler * @@ -128,7 +129,8 @@ async function createWorkspaceWithCleanup( projectPath: string, branchName: string, trunkBranch: string, - runtimeConfig?: RuntimeConfig + runtimeConfig?: RuntimeConfig, + options?: { fetchLatest?: boolean } ): Promise<{ result: | { success: true; metadata: FrontendWorkspaceMetadata } @@ -140,7 +142,8 @@ async function createWorkspaceWithCleanup( projectPath, branchName, trunkBranch, - runtimeConfig + runtimeConfig, + options ); const cleanup = async () => { @@ -362,6 +365,126 @@ describeIntegration("WORKSPACE_CREATE with both runtimes", () => { }, TEST_TIMEOUT_MS ); + + test.concurrent( + "bases new workspace branch on origin head when fetchLatest is enabled", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + const remoteBareDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-remote-bare-")); + const remoteCloneBase = await fs.mkdtemp(path.join(os.tmpdir(), "mux-remote-clone-")); + const remoteCloneDir = path.join(remoteCloneBase, "clone"); + + let cleanupBaseline: (() => Promise) | undefined; + let cleanupFetch: (() => Promise) | undefined; + + try { + await execAsync("git init --bare .", { cwd: remoteBareDir }); + + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + await execAsync(`git remote add origin "${remoteBareDir}"`, { cwd: tempGitRepo }); + await execAsync(`git push -u origin ${trunkBranch}`, { cwd: tempGitRepo }); + + await execAsync(`git clone "${remoteBareDir}" "${remoteCloneDir}"`); + await execAsync(`git config user.email "test@example.com"`, { cwd: remoteCloneDir }); + await execAsync(`git config user.name "Test User"`, { cwd: remoteCloneDir }); + await execAsync(`echo "remote" >> REMOTE.md`, { cwd: remoteCloneDir }); + await execAsync(`git add REMOTE.md`, { cwd: remoteCloneDir }); + await execAsync(`git commit -m "Upstream commit"`, { cwd: remoteCloneDir }); + await execAsync(`git push origin ${trunkBranch}`, { cwd: remoteCloneDir }); + + const remoteHead = ( + await execAsync(`git rev-parse HEAD`, { cwd: remoteCloneDir }) + ).stdout.trim(); + const localHeadBefore = ( + await execAsync(`git rev-parse ${trunkBranch}`, { cwd: tempGitRepo }) + ).stdout.trim(); + expect(remoteHead).not.toBe(localHeadBefore); + + const baselineBranchName = generateBranchName("no-fetch"); + const baselineRuntimeConfig = getRuntimeConfig(baselineBranchName); + const baseline = await createWorkspaceWithCleanup( + env, + tempGitRepo, + baselineBranchName, + trunkBranch, + baselineRuntimeConfig, + { fetchLatest: false } + ); + cleanupBaseline = baseline.cleanup; + expect(baseline.result.success).toBe(true); + if (!baseline.result.success) { + throw new Error( + `Baseline workspace creation failed: ${baseline.result.error ?? "unknown error"}` + ); + } + + if (type === "ssh") { + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + } + + const baselineHeadResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + baseline.result.metadata.id, + "git rev-parse HEAD" + ); + expect(baselineHeadResult.success).toBe(true); + expect(baselineHeadResult.data.success).toBe(true); + const baselineHead = baselineHeadResult.data.output.trim(); + expect(baselineHead).toBe(localHeadBefore); + + await cleanupBaseline(); + cleanupBaseline = undefined; + + const fetchBranchName = generateBranchName("fetch-latest"); + const fetchRuntimeConfig = getRuntimeConfig(fetchBranchName); + const fetchWorkspace = await createWorkspaceWithCleanup( + env, + tempGitRepo, + fetchBranchName, + trunkBranch, + fetchRuntimeConfig, + { fetchLatest: true } + ); + cleanupFetch = fetchWorkspace.cleanup; + expect(fetchWorkspace.result.success).toBe(true); + if (!fetchWorkspace.result.success) { + throw new Error( + `Fetch-enabled workspace creation failed: ${fetchWorkspace.result.error ?? "unknown error"}` + ); + } + + if (type === "ssh") { + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + } + + const fetchHeadResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + fetchWorkspace.result.metadata.id, + "git rev-parse HEAD" + ); + expect(fetchHeadResult.success).toBe(true); + expect(fetchHeadResult.data.success).toBe(true); + const fetchHead = fetchHeadResult.data.output.trim(); + expect(fetchHead).toBe(remoteHead); + + await cleanupFetch(); + cleanupFetch = undefined; + } finally { + if (cleanupBaseline) { + await cleanupBaseline(); + } + if (cleanupFetch) { + await cleanupFetch(); + } + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + await cleanupTempGitRepo(remoteCloneBase); + await cleanupTempGitRepo(remoteBareDir); + } + }, + TEST_TIMEOUT_MS + ); }); describe("Init hook execution", () => { @@ -370,7 +493,6 @@ describeIntegration("WORKSPACE_CREATE with both runtimes", () => { async () => { const env = await createTestEnvironment(); const tempGitRepo = await createTempGitRepo(); - try { // Create and commit init hook await createInitHook( diff --git a/tests/ipcMain/helpers.ts b/tests/ipcMain/helpers.ts index 71a38169a..eea6319d6 100644 --- a/tests/ipcMain/helpers.ts +++ b/tests/ipcMain/helpers.ts @@ -94,7 +94,8 @@ export async function createWorkspace( projectPath: string, branchName: string, trunkBranch?: string, - runtimeConfig?: import("../../src/common/types/runtime").RuntimeConfig + runtimeConfig?: import("../../src/common/types/runtime").RuntimeConfig, + options?: { fetchLatest?: boolean } ): Promise< { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string } > { @@ -108,7 +109,8 @@ export async function createWorkspace( projectPath, branchName, resolvedTrunk, - runtimeConfig + runtimeConfig, + options )) as { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string }; } @@ -146,7 +148,8 @@ export async function createWorkspaceWithInit( branchName: string, runtimeConfig?: RuntimeConfig, waitForInit: boolean = false, - isSSH: boolean = false + isSSH: boolean = false, + options?: { fetchLatest?: boolean } ): Promise<{ workspaceId: string; workspacePath: string; cleanup: () => Promise }> { const trunkBranch = await detectDefaultTrunkBranch(projectPath); @@ -155,7 +158,8 @@ export async function createWorkspaceWithInit( projectPath, branchName, trunkBranch, - runtimeConfig + runtimeConfig, + options ); if (!result.success) {