Skip to content

Commit c27e082

Browse files
committed
🤖 Fix tilde expansion in git clone target path
Fix bug where git clone failed when workdir used tilde paths. Problem reported: Repository cloned successfully but checkout failed with: ERROR: Failed to checkout branch: bash: line 1: cd: /root/cmux/r3: No such file or directory Root cause: - git clone command used: git clone bundle "~/cmux/r3" - Bash doesn't expand ~ inside double quotes! - So git creates literal directory named "~" instead of $HOME - Clone appears to succeed but creates wrong directory - Later checkout fails because $HOME/cmux/r3 doesn't exist Solution: - Created expandTilde() helper to centralize tilde expansion logic - Expand workdir path BEFORE JSON.stringify'ing it for git clone - expandTilde("~/cmux/r3") returns "$HOME/cmux/r3" - Bash expands $HOME inside double quotes ✅ Changes: - Added expandTilde(path) helper method - Updated exec() to use helper for cwd expansion - Fixed syncProjectToRemote() to expand workdir before git clone All 14 integration tests passing (includes tilde path test). _Generated with `cmux`_
1 parent 8594d50 commit c27e082

File tree

1 file changed

+91
-45
lines changed

1 file changed

+91
-45
lines changed

src/runtime/SSHRuntime.ts

Lines changed: 91 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ export class SSHRuntime implements Runtime {
4747
this.config = config;
4848
}
4949

50+
51+
/**
52+
* Expand tilde in path for use in remote commands
53+
* Bash doesn't expand ~ when it's inside quotes, so we need to do it manually
54+
*/
55+
private expandTilde(path: string): string {
56+
if (path === "~") {
57+
return "$HOME";
58+
} else if (path.startsWith("~/")) {
59+
return "$HOME/" + path.slice(2);
60+
}
61+
return path;
62+
}
63+
5064
/**
5165
* Execute command over SSH with streaming I/O
5266
*/
@@ -63,10 +77,7 @@ export class SSHRuntime implements Runtime {
6377
}
6478

6579
// Expand ~/path to $HOME/path before quoting (~ doesn't expand in quotes)
66-
let cwd = options.cwd;
67-
if (cwd.startsWith("~/")) {
68-
cwd = "$HOME/" + cwd.slice(2);
69-
}
80+
const cwd = this.expandTilde(options.cwd);
7081

7182
// Build full command with cwd and env
7283
const fullCommand = `cd ${JSON.stringify(cwd)} && ${envPrefix}${command}`;
@@ -313,56 +324,91 @@ export class SSHRuntime implements Runtime {
313324
* - Simpler implementation
314325
*/
315326
private async syncProjectToRemote(projectPath: string, initLogger: InitLogger): Promise<void> {
316-
return new Promise<void>((resolve, reject) => {
317-
// Build SSH args
318-
const sshArgs = this.buildSSHArgs(true);
319-
320-
// For paths starting with ~/, expand to $HOME
321-
let remoteWorkdir: string;
322-
if (this.config.workdir.startsWith("~/")) {
323-
const pathWithoutTilde = this.config.workdir.slice(2);
324-
remoteWorkdir = `"\\\\$HOME/${pathWithoutTilde}"`; // Escape $ so local shell doesn't expand it
325-
} else {
326-
remoteWorkdir = JSON.stringify(this.config.workdir);
327-
}
327+
// Use timestamp-based bundle path to avoid conflicts (simpler than $$)
328+
const timestamp = Date.now();
329+
const bundleTempPath = `~/.cmux-bundle-${timestamp}.bundle`;
328330

329-
// Use git bundle to create a packfile of all refs, pipe through ssh for cloning
330-
// This creates a real git repository on the remote with full history
331-
// Wrap remote commands in bash to avoid issues with non-bash shells (fish, zsh, etc)
332-
// Save bundle to temp file on remote, clone from it, then clean up
333-
// Use $$ for PID to avoid conflicts, escape $ so it's evaluated on remote
334-
const bundleTempPath = `\\$HOME/.cmux-bundle-\\$\\$.bundle`;
335-
const command = `cd ${JSON.stringify(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "bash -c 'cat > ${bundleTempPath} && git clone --quiet ${bundleTempPath} ${remoteWorkdir} && rm ${bundleTempPath}'"`;
336-
337-
log.debug(`Starting git bundle+ssh: ${command}`);
338-
const proc = spawn("bash", ["-c", command]);
339-
340-
// Use helper to stream output and prevent buffer overflow
341-
streamProcessToLogger(proc, initLogger, {
342-
logStdout: false, // bundle stdout is binary, drain silently
343-
logStderr: true, // Errors go to init stream
344-
command: `git bundle+ssh: ${command}`, // Log the full command
345-
});
331+
try {
332+
// Step 1: Create bundle locally and pipe to remote file via SSH
333+
initLogger.logStep(`Creating git bundle...`);
334+
await new Promise<void>((resolve, reject) => {
335+
const sshArgs = this.buildSSHArgs(true);
336+
const command = `cd ${JSON.stringify(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`;
337+
338+
log.debug(`Creating bundle: ${command}`);
339+
const proc = spawn("bash", ["-c", command]);
340+
341+
streamProcessToLogger(proc, initLogger, {
342+
logStdout: false,
343+
logStderr: true,
344+
});
346345

347-
let stderr = "";
348-
proc.stderr.on("data", (data: Buffer) => {
349-
stderr += data.toString();
346+
let stderr = "";
347+
proc.stderr.on("data", (data: Buffer) => {
348+
stderr += data.toString();
349+
});
350+
351+
proc.on("close", (code) => {
352+
if (code === 0) {
353+
resolve();
354+
} else {
355+
reject(new Error(`Failed to create bundle: ${stderr}`));
356+
}
357+
});
358+
359+
proc.on("error", (err) => {
360+
reject(err);
361+
});
350362
});
351363

352-
proc.on("close", (code) => {
353-
if (code === 0) {
354-
resolve();
355-
} else {
356-
reject(new Error(`git bundle+ssh failed with exit code ${code ?? "unknown"}: ${stderr}`));
357-
}
364+
// Step 2: Clone from bundle on remote using this.exec (handles tilde expansion)
365+
initLogger.logStep(`Cloning repository on remote...`);
366+
const expandedWorkdir = this.expandTilde(this.config.workdir);
367+
const cloneStream = this.exec(`git clone --quiet ${bundleTempPath} ${JSON.stringify(expandedWorkdir)}`, {
368+
cwd: "~",
369+
timeout: 300, // 5 minutes for clone
358370
});
359371

360-
proc.on("error", (err) => {
361-
reject(err);
372+
const [cloneStdout, cloneStderr, cloneExitCode] = await Promise.all([
373+
streamToString(cloneStream.stdout),
374+
streamToString(cloneStream.stderr),
375+
cloneStream.exitCode,
376+
]);
377+
378+
if (cloneExitCode !== 0) {
379+
throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`);
380+
}
381+
382+
// Step 3: Remove bundle file
383+
initLogger.logStep(`Cleaning up bundle file...`);
384+
const rmStream = this.exec(`rm ${bundleTempPath}`, {
385+
cwd: "~",
386+
timeout: 10,
362387
});
363-
});
388+
389+
const rmExitCode = await rmStream.exitCode;
390+
if (rmExitCode !== 0) {
391+
log.info(`Failed to remove bundle file ${bundleTempPath}, but continuing`);
392+
}
393+
394+
initLogger.logStep(`Repository cloned successfully`);
395+
} catch (error) {
396+
// Try to clean up bundle file on error
397+
try {
398+
const rmStream = this.exec(`rm -f ${bundleTempPath}`, {
399+
cwd: "~",
400+
timeout: 10,
401+
});
402+
await rmStream.exitCode;
403+
} catch {
404+
// Ignore cleanup errors
405+
}
406+
407+
throw error;
408+
}
364409
}
365410

411+
366412
/**
367413
* Run .cmux/init hook on remote machine if it exists
368414
*/

0 commit comments

Comments
 (0)