Skip to content

Commit fe546d2

Browse files
authored
Fix SSH workspace origin remote forwarding (#430)
Fixes the SSH create workspace flow to properly forward the `origin` remote instead of leaving it pointing to the temporary bundle file. ## Changes ### Implementation - **Forward actual origin URL**: After cloning from bundle on remote, set origin to the actual repository URL - **Remove bundle origin**: If no origin exists locally, remove the bundle-pointing origin to avoid confusion - **Refactored code**: Use `execAsync` utility instead of manual spawn pattern (no code duplication) - **Better logging**: Errors go to init log (user-visible) instead of debug log ### Testing - Added targeted SSH-specific test outside matrix tests - Test verifies origin remote points to actual repository URL, not bundle path - Exported `streamToString` for test reuse (no duplication) ## Behavior - Non-fatal: If setting origin fails, logs warning but continues (won't break workspace creation) - Filters bundle paths: Prevents propagation of temporary bundle URLs - Gracefully handles missing origin remotes Follows TDD approach with test written first.
1 parent 86ef049 commit fe546d2

File tree

2 files changed

+146
-6
lines changed

2 files changed

+146
-6
lines changed

src/runtime/SSHRuntime.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion";
2424
import { findBashPath } from "./executablePaths";
2525
import { getProjectName } from "../utils/runtime/helpers";
2626
import { getErrorMessage } from "../utils/errors";
27+
import { execAsync } from "../utils/disposableExec";
2728

2829
/**
2930
* Shescape instance for bash shell escaping.
@@ -372,11 +373,28 @@ export class SSHRuntime implements Runtime {
372373
const bundleTempPath = `~/.cmux-bundle-${timestamp}.bundle`;
373374

374375
try {
375-
// Step 1: Create bundle locally and pipe to remote file via SSH
376+
// Step 1: Get origin URL from local repository (if it exists)
377+
let originUrl: string | null = null;
378+
try {
379+
using proc = execAsync(
380+
`cd ${shescape.quote(projectPath)} && git remote get-url origin 2>/dev/null || true`
381+
);
382+
const { stdout } = await proc.result;
383+
const url = stdout.trim();
384+
// Only use URL if it's not a bundle path (avoids propagating bundle paths)
385+
if (url && !url.includes(".bundle") && !url.includes(".cmux-bundle")) {
386+
originUrl = url;
387+
}
388+
} catch (error) {
389+
// If we can't get origin, continue without it
390+
initLogger.logStderr(`Could not get origin URL: ${getErrorMessage(error)}`);
391+
}
392+
393+
// Step 2: Create bundle locally and pipe to remote file via SSH
376394
initLogger.logStep(`Creating git bundle...`);
377395
await new Promise<void>((resolve, reject) => {
378396
const sshArgs = this.buildSSHArgs(true);
379-
const command = `cd ${JSON.stringify(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`;
397+
const command = `cd ${shescape.quote(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`;
380398

381399
log.debug(`Creating bundle: ${command}`);
382400
const bashPath = findBashPath();
@@ -405,7 +423,7 @@ export class SSHRuntime implements Runtime {
405423
});
406424
});
407425

408-
// Step 2: Clone from bundle on remote using this.exec
426+
// Step 3: Clone from bundle on remote using this.exec
409427
initLogger.logStep(`Cloning repository on remote...`);
410428

411429
// Expand tilde in destination path for git clone
@@ -427,7 +445,37 @@ export class SSHRuntime implements Runtime {
427445
throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`);
428446
}
429447

430-
// Step 3: Remove bundle file
448+
// Step 4: Update origin remote if we have an origin URL
449+
if (originUrl) {
450+
initLogger.logStep(`Setting origin remote to ${originUrl}...`);
451+
const setOriginStream = await this.exec(
452+
`git -C ${cloneDestPath} remote set-url origin ${shescape.quote(originUrl)}`,
453+
{
454+
cwd: "~",
455+
timeout: 10,
456+
}
457+
);
458+
459+
const setOriginExitCode = await setOriginStream.exitCode;
460+
if (setOriginExitCode !== 0) {
461+
const stderr = await streamToString(setOriginStream.stderr);
462+
log.info(`Failed to set origin remote: ${stderr}`);
463+
// Continue anyway - this is not fatal
464+
}
465+
} else {
466+
// No origin in local repo, remove the origin that points to bundle
467+
initLogger.logStep(`Removing bundle origin remote...`);
468+
const removeOriginStream = await this.exec(
469+
`git -C ${cloneDestPath} remote remove origin 2>/dev/null || true`,
470+
{
471+
cwd: "~",
472+
timeout: 10,
473+
}
474+
);
475+
await removeOriginStream.exitCode;
476+
}
477+
478+
// Step 5: Remove bundle file
431479
initLogger.logStep(`Cleaning up bundle file...`);
432480
const rmStream = await this.exec(`rm ${bundleTempPath}`, {
433481
cwd: "~",
@@ -615,7 +663,7 @@ export class SSHRuntime implements Runtime {
615663
// We create new branches from HEAD instead of the trunkBranch name to avoid issues
616664
// where the local repo's trunk name doesn't match the cloned repo's default branch
617665
initLogger.logStep(`Checking out branch: ${branchName}`);
618-
const checkoutCmd = `(git checkout ${JSON.stringify(branchName)} 2>/dev/null || git checkout -b ${JSON.stringify(branchName)} HEAD)`;
666+
const checkoutCmd = `(git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} HEAD)`;
619667

620668
const checkoutStream = await this.exec(checkoutCmd, {
621669
cwd: workspacePath, // Use the full workspace path for git operations
@@ -826,7 +874,7 @@ export class SSHRuntime implements Runtime {
826874
/**
827875
* Helper to convert a ReadableStream to a string
828876
*/
829-
async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
877+
export async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
830878
const reader = stream.getReader();
831879
const decoder = new TextDecoder("utf-8");
832880
let result = "";

tests/ipcMain/createWorkspace.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import {
2727
} from "../runtime/ssh-fixture";
2828
import type { RuntimeConfig } from "../../src/types/runtime";
2929
import type { FrontendWorkspaceMetadata } from "../../src/types/workspace";
30+
import { createRuntime } from "../../src/runtime/runtimeFactory";
31+
import type { SSHRuntime } from "../../src/runtime/SSHRuntime";
32+
import { streamToString } from "../../src/runtime/SSHRuntime";
3033

3134
const execAsync = promisify(exec);
3235

@@ -722,4 +725,93 @@ echo "Init hook executed with tilde path"
722725
});
723726
}
724727
);
728+
729+
// SSH-specific tests (outside matrix)
730+
describe("SSH-specific behavior", () => {
731+
test.concurrent(
732+
"forwards origin remote instead of bundle path",
733+
async () => {
734+
// Skip if SSH server not available
735+
if (!sshConfig) {
736+
console.log("Skipping SSH-specific test: SSH server not available");
737+
return;
738+
}
739+
740+
const env = await createTestEnvironment();
741+
const tempGitRepo = await createTempGitRepo();
742+
743+
try {
744+
// Set up a real origin remote in the test repo
745+
const originUrl = "https://github.com/example/test-repo.git";
746+
await execAsync(`git remote add origin ${originUrl}`, {
747+
cwd: tempGitRepo,
748+
});
749+
750+
// Verify origin was added
751+
const { stdout: originCheck } = await execAsync(`git remote get-url origin`, {
752+
cwd: tempGitRepo,
753+
});
754+
expect(originCheck.trim()).toBe(originUrl);
755+
756+
const branchName = generateBranchName();
757+
const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo);
758+
759+
const runtimeConfig: RuntimeConfig = {
760+
type: "ssh",
761+
host: "testuser@localhost",
762+
srcBaseDir: "~/workspace",
763+
identityFile: sshConfig.privateKeyPath,
764+
port: sshConfig.port,
765+
};
766+
767+
const { result, cleanup } = await createWorkspaceWithCleanup(
768+
env,
769+
tempGitRepo,
770+
branchName,
771+
trunkBranch,
772+
runtimeConfig
773+
);
774+
775+
try {
776+
expect(result.success).toBe(true);
777+
if (!result.success) return;
778+
779+
// Wait for init to complete
780+
await new Promise((resolve) => setTimeout(resolve, SSH_INIT_WAIT_MS));
781+
782+
// Create runtime to check remote on SSH host
783+
const runtime = createRuntime(runtimeConfig);
784+
const workspacePath = runtime.getWorkspacePath(tempGitRepo, branchName);
785+
786+
// Check that origin remote exists and points to the original URL, not the bundle
787+
const checkOriginCmd = `git -C ${workspacePath} remote get-url origin`;
788+
const originStream = await (runtime as SSHRuntime).exec(checkOriginCmd, {
789+
cwd: "~",
790+
timeout: 10,
791+
});
792+
793+
const [stdout, stderr, exitCode] = await Promise.all([
794+
streamToString(originStream.stdout),
795+
streamToString(originStream.stderr),
796+
originStream.exitCode,
797+
]);
798+
799+
expect(exitCode).toBe(0);
800+
const remoteUrl = stdout.trim();
801+
802+
// Should be the original origin URL, not the bundle path
803+
expect(remoteUrl).toBe(originUrl);
804+
expect(remoteUrl).not.toContain(".bundle");
805+
expect(remoteUrl).not.toContain(".cmux-bundle");
806+
} finally {
807+
await cleanup();
808+
}
809+
} finally {
810+
await cleanupTestEnvironment(env);
811+
await cleanupTempGitRepo(tempGitRepo);
812+
}
813+
},
814+
TEST_TIMEOUT_MS
815+
);
816+
});
725817
});

0 commit comments

Comments
 (0)