Skip to content
Merged
34 changes: 22 additions & 12 deletions src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -669,13 +683,7 @@ export class SSHRuntime implements Runtime {
}

async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
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)
Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions tests/ipcMain/createWorkspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
13 changes: 13 additions & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}