diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index d4be4d4330..da70e5604b 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -480,7 +480,21 @@ export class SSHRuntime implements Runtime { throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`); } - // Step 4: Update origin remote if we have an origin URL + // Step 4: Create local tracking branches for all remote branches + // This ensures that branch names like "custom-trunk" can be used directly + // in git checkout commands, rather than needing "origin/custom-trunk" + initLogger.logStep(`Creating local tracking branches...`); + const createTrackingBranchesStream = await this.exec( + `cd ${cloneDestPath} && for branch in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin/ | grep -v 'origin/HEAD'); do localname=\${branch#origin/}; git show-ref --verify --quiet refs/heads/$localname || git branch $localname $branch; done`, + { + cwd: "~", + timeout: 30, + } + ); + await createTrackingBranchesStream.exitCode; + // Don't fail if this fails - some branches may already exist + + // Step 5: Update origin remote if we have an origin URL if (originUrl) { initLogger.logStep(`Setting origin remote to ${originUrl}...`); const setOriginStream = await this.exec( @@ -669,13 +683,7 @@ export class SSHRuntime implements Runtime { } async initWorkspace(params: WorkspaceInitParams): Promise { - const { - projectPath, - branchName, - trunkBranch: _trunkBranch, - workspacePath, - initLogger, - } = params; + const { projectPath, branchName, trunkBranch, workspacePath, initLogger } = params; try { // 1. Sync project to remote (opportunistic rsync with scp fallback) @@ -694,11 +702,13 @@ export class SSHRuntime implements Runtime { initLogger.logStep("Files synced successfully"); // 2. Checkout branch remotely - // Note: After git clone, HEAD is already checked out to the default branch from the bundle - // We create new branches from HEAD instead of the trunkBranch name to avoid issues - // where the local repo's trunk name doesn't match the cloned repo's default branch + // If branch exists locally, check it out; otherwise create it from the specified trunk branch + // Note: We've already created local branches for all remote refs in syncProjectToRemote initLogger.logStep(`Checking out branch: ${branchName}`); - const checkoutCmd = `(git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} HEAD)`; + + // Try to checkout existing branch, or create new branch from trunk + // Since we've created local branches for all remote refs, we can use branch names directly + const checkoutCmd = `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`; const checkoutStream = await this.exec(checkoutCmd, { cwd: workspacePath, // Use the full workspace path for git operations diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index db6fc25a9f..44fcbb07a1 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -273,6 +273,95 @@ describeIntegration("WORKSPACE_CREATE with both runtimes", () => { }, TEST_TIMEOUT_MS ); + + test.concurrent( + "creates new branch from specified trunk branch, not from default branch", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Create a custom trunk branch with a unique commit + const customTrunkBranch = "custom-trunk"; + await execAsync( + `git checkout -b ${customTrunkBranch} && echo "custom-trunk-content" > trunk-file.txt && git add . && git commit -m "Custom trunk commit"`, + { cwd: tempGitRepo } + ); + + // Create a different branch (which will become the default if we checkout to it) + const otherBranch = "other-branch"; + await execAsync( + `git checkout -b ${otherBranch} && echo "other-content" > other-file.txt && git add . && git commit -m "Other branch commit"`, + { cwd: tempGitRepo } + ); + + // Switch back to the original default branch + const defaultBranch = await detectDefaultTrunkBranch(tempGitRepo); + await execAsync(`git checkout ${defaultBranch}`, { cwd: tempGitRepo }); + + // Now create a workspace specifying custom-trunk as the trunk branch + const newBranchName = generateBranchName("from-custom-trunk"); + const runtimeConfig = getRuntimeConfig(newBranchName); + + const { result, cleanup } = await createWorkspaceWithCleanup( + env, + tempGitRepo, + newBranchName, + customTrunkBranch, // Specify custom trunk branch + runtimeConfig + ); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error( + `Failed to create workspace from custom trunk '${customTrunkBranch}': ${result.error}` + ); + } + + // Wait for workspace initialization to complete + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Verify the new branch was created from custom-trunk, not from default branch + // Use WORKSPACE_EXECUTE_BASH to check files (works for both local and SSH runtimes) + + // Check that trunk-file.txt exists (from custom-trunk) + const checkTrunkFileResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + result.metadata.id, + `test -f trunk-file.txt && echo "exists" || echo "missing"` + ); + expect(checkTrunkFileResult.success).toBe(true); + expect(checkTrunkFileResult.data.success).toBe(true); + expect(checkTrunkFileResult.data.output.trim()).toBe("exists"); + + // Check that other-file.txt does NOT exist (from other-branch) + const checkOtherFileResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + result.metadata.id, + `test -f other-file.txt && echo "exists" || echo "missing"` + ); + expect(checkOtherFileResult.success).toBe(true); + expect(checkOtherFileResult.data.success).toBe(true); + expect(checkOtherFileResult.data.output.trim()).toBe("missing"); + + // Verify git log shows the custom trunk commit + const gitLogResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + result.metadata.id, + `git log --oneline --all` + ); + expect(gitLogResult.success).toBe(true); + expect(gitLogResult.data.success).toBe(true); + expect(gitLogResult.data.output).toContain("Custom trunk commit"); + + await cleanup(); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + TEST_TIMEOUT_MS + ); }); describe("Init hook execution", () => { diff --git a/tests/setup.ts b/tests/setup.ts index daf20d3bf5..ad8144a9b2 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -9,3 +9,16 @@ require("disposablestack/auto"); assert.equal(typeof Symbol.dispose, "symbol"); assert.equal(typeof Symbol.asyncDispose, "symbol"); + +// Polyfill File for Node 18 (undici needs it) +if (typeof globalThis.File === "undefined") { + (globalThis as any).File = class File extends Blob { + constructor(bits: BlobPart[], name: string, options?: FilePropertyBag) { + super(bits, options); + this.name = name; + this.lastModified = options?.lastModified ?? Date.now(); + } + name: string; + lastModified: number; + }; +}