diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index 7c19f3d27d..c6fb452f73 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -24,6 +24,7 @@ import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion"; import { findBashPath } from "./executablePaths"; import { getProjectName } from "../utils/runtime/helpers"; import { getErrorMessage } from "../utils/errors"; +import { execAsync } from "../utils/disposableExec"; /** * Shescape instance for bash shell escaping. @@ -372,11 +373,28 @@ export class SSHRuntime implements Runtime { const bundleTempPath = `~/.cmux-bundle-${timestamp}.bundle`; try { - // Step 1: Create bundle locally and pipe to remote file via SSH + // Step 1: Get origin URL from local repository (if it exists) + let originUrl: string | null = null; + try { + using proc = execAsync( + `cd ${shescape.quote(projectPath)} && git remote get-url origin 2>/dev/null || true` + ); + const { stdout } = await proc.result; + const url = stdout.trim(); + // Only use URL if it's not a bundle path (avoids propagating bundle paths) + if (url && !url.includes(".bundle") && !url.includes(".cmux-bundle")) { + originUrl = url; + } + } catch (error) { + // If we can't get origin, continue without it + initLogger.logStderr(`Could not get origin URL: ${getErrorMessage(error)}`); + } + + // Step 2: Create bundle locally and pipe to remote file via SSH initLogger.logStep(`Creating git bundle...`); await new Promise((resolve, reject) => { const sshArgs = this.buildSSHArgs(true); - const command = `cd ${JSON.stringify(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`; + const command = `cd ${shescape.quote(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`; log.debug(`Creating bundle: ${command}`); const bashPath = findBashPath(); @@ -405,7 +423,7 @@ export class SSHRuntime implements Runtime { }); }); - // Step 2: Clone from bundle on remote using this.exec + // Step 3: Clone from bundle on remote using this.exec initLogger.logStep(`Cloning repository on remote...`); // Expand tilde in destination path for git clone @@ -427,7 +445,37 @@ export class SSHRuntime implements Runtime { throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`); } - // Step 3: Remove bundle file + // Step 4: Update origin remote if we have an origin URL + if (originUrl) { + initLogger.logStep(`Setting origin remote to ${originUrl}...`); + const setOriginStream = await this.exec( + `git -C ${cloneDestPath} remote set-url origin ${shescape.quote(originUrl)}`, + { + cwd: "~", + timeout: 10, + } + ); + + const setOriginExitCode = await setOriginStream.exitCode; + if (setOriginExitCode !== 0) { + const stderr = await streamToString(setOriginStream.stderr); + log.info(`Failed to set origin remote: ${stderr}`); + // Continue anyway - this is not fatal + } + } else { + // No origin in local repo, remove the origin that points to bundle + initLogger.logStep(`Removing bundle origin remote...`); + const removeOriginStream = await this.exec( + `git -C ${cloneDestPath} remote remove origin 2>/dev/null || true`, + { + cwd: "~", + timeout: 10, + } + ); + await removeOriginStream.exitCode; + } + + // Step 5: Remove bundle file initLogger.logStep(`Cleaning up bundle file...`); const rmStream = await this.exec(`rm ${bundleTempPath}`, { cwd: "~", @@ -615,7 +663,7 @@ export class SSHRuntime implements Runtime { // 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 initLogger.logStep(`Checking out branch: ${branchName}`); - const checkoutCmd = `(git checkout ${JSON.stringify(branchName)} 2>/dev/null || git checkout -b ${JSON.stringify(branchName)} HEAD)`; + const checkoutCmd = `(git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} HEAD)`; const checkoutStream = await this.exec(checkoutCmd, { cwd: workspacePath, // Use the full workspace path for git operations @@ -826,7 +874,7 @@ export class SSHRuntime implements Runtime { /** * Helper to convert a ReadableStream to a string */ -async function streamToString(stream: ReadableStream): Promise { +export async function streamToString(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder("utf-8"); let result = ""; diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index e8c3f2550a..db6fc25a9f 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -27,6 +27,9 @@ import { } from "../runtime/ssh-fixture"; import type { RuntimeConfig } from "../../src/types/runtime"; import type { FrontendWorkspaceMetadata } from "../../src/types/workspace"; +import { createRuntime } from "../../src/runtime/runtimeFactory"; +import type { SSHRuntime } from "../../src/runtime/SSHRuntime"; +import { streamToString } from "../../src/runtime/SSHRuntime"; const execAsync = promisify(exec); @@ -722,4 +725,93 @@ echo "Init hook executed with tilde path" }); } ); + + // SSH-specific tests (outside matrix) + describe("SSH-specific behavior", () => { + test.concurrent( + "forwards origin remote instead of bundle path", + async () => { + // Skip if SSH server not available + if (!sshConfig) { + console.log("Skipping SSH-specific test: SSH server not available"); + return; + } + + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Set up a real origin remote in the test repo + const originUrl = "https://github.com/example/test-repo.git"; + await execAsync(`git remote add origin ${originUrl}`, { + cwd: tempGitRepo, + }); + + // Verify origin was added + const { stdout: originCheck } = await execAsync(`git remote get-url origin`, { + cwd: tempGitRepo, + }); + expect(originCheck.trim()).toBe(originUrl); + + const branchName = generateBranchName(); + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + + const runtimeConfig: RuntimeConfig = { + type: "ssh", + host: "testuser@localhost", + srcBaseDir: "~/workspace", + identityFile: sshConfig.privateKeyPath, + port: sshConfig.port, + }; + + const { result, cleanup } = await createWorkspaceWithCleanup( + env, + tempGitRepo, + branchName, + trunkBranch, + runtimeConfig + ); + + try { + expect(result.success).toBe(true); + if (!result.success) return; + + // Wait for init to complete + await new Promise((resolve) => setTimeout(resolve, SSH_INIT_WAIT_MS)); + + // Create runtime to check remote on SSH host + const runtime = createRuntime(runtimeConfig); + const workspacePath = runtime.getWorkspacePath(tempGitRepo, branchName); + + // Check that origin remote exists and points to the original URL, not the bundle + const checkOriginCmd = `git -C ${workspacePath} remote get-url origin`; + const originStream = await (runtime as SSHRuntime).exec(checkOriginCmd, { + cwd: "~", + timeout: 10, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(originStream.stdout), + streamToString(originStream.stderr), + originStream.exitCode, + ]); + + expect(exitCode).toBe(0); + const remoteUrl = stdout.trim(); + + // Should be the original origin URL, not the bundle path + expect(remoteUrl).toBe(originUrl); + expect(remoteUrl).not.toContain(".bundle"); + expect(remoteUrl).not.toContain(".cmux-bundle"); + } finally { + await cleanup(); + } + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + TEST_TIMEOUT_MS + ); + }); });