Skip to content
Merged
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
60 changes: 54 additions & 6 deletions src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void>((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();
Expand Down Expand Up @@ -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
Expand All @@ -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: "~",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -826,7 +874,7 @@ export class SSHRuntime implements Runtime {
/**
* Helper to convert a ReadableStream to a string
*/
async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
export async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder("utf-8");
let result = "";
Expand Down
92 changes: 92 additions & 0 deletions tests/ipcMain/createWorkspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
);
});
});