From 3935419144ff8099f5882604f6aa5b28dbfdf2bd Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 11 May 2026 14:29:01 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20branch=20SSH=20workspaces?= =?UTF-8?q?=20from=20upstream=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh SSH/Coder workspace creation now treats the selected source branch as the upstream remote-tracking branch when available, leaving refs/mux-bundle/* as a local snapshot fallback only.\n\n---\n\n_Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `3.34`_\n\n --- .../runtime/SSHRuntime.syncContract.test.ts | 74 +++++++++++++++++++ src/node/runtime/SSHRuntime.ts | 74 +++++++++++++------ .../runtime/submoduleSyncRuntimes.test.ts | 2 +- 3 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/node/runtime/SSHRuntime.syncContract.test.ts b/src/node/runtime/SSHRuntime.syncContract.test.ts index 042939b14e..ff261832b1 100644 --- a/src/node/runtime/SSHRuntime.syncContract.test.ts +++ b/src/node/runtime/SSHRuntime.syncContract.test.ts @@ -133,6 +133,24 @@ interface SnapshotPrivateApi { resolveLocalSyncRefManifest(projectPath: string): Promise; } +interface FreshWorkspaceSourcePrivateApi { + resolveFreshWorkspaceSourceBase( + baseRepoPathArg: string, + trunkBranch: string, + fetchedOrigin: boolean, + fallbackRef: string | null, + initLogger: InitLogger, + abortSignal?: AbortSignal + ): Promise; + fetchOriginTrunk( + workspacePath: string, + trunkBranch: string, + initLogger: InitLogger, + abortSignal?: AbortSignal, + nhp?: string + ): Promise; +} + function createLayout(): RemoteProjectLayout { return { projectId: "project-id", @@ -255,6 +273,62 @@ describe("SSHRuntime authoritative sync contract", () => { expect(pushCalls[0]).not.toContain("+refs/tags/*:refs/tags/*"); }); + it("uses the upstream source branch over the synced local snapshot for fresh workspaces", async () => { + const runtime = new CommandCaptureSSHRuntime(); + const privateApi = runtime as unknown as FreshWorkspaceSourcePrivateApi; + + const sourceBase = await privateApi.resolveFreshWorkspaceSourceBase( + "/remote/src/project/.mux-base.git", + "main", + true, + "refs/mux-bundle/main", + noopInitLogger + ); + + expect(sourceBase).toBe("origin/main"); + expect(runtime.commands.some((command) => command.includes("merge-base --is-ancestor"))).toBe( + false + ); + expect(runtime.commands).toContain( + "git -C /remote/src/project/.mux-base.git rev-parse --verify --quiet 'refs/remotes/origin/main'" + ); + }); + + it("falls back to the local snapshot explicitly when the upstream source is unavailable", async () => { + const runtime = new CommandCaptureSSHRuntime(); + const privateApi = runtime as unknown as FreshWorkspaceSourcePrivateApi; + const stderrLines: string[] = []; + const initLogger = { + ...noopInitLogger, + logStderr(line: string) { + stderrLines.push(line); + }, + }; + + const sourceBase = await privateApi.resolveFreshWorkspaceSourceBase( + "/remote/src/project/.mux-base.git", + "main", + false, + "refs/mux-bundle/main", + initLogger + ); + + expect(sourceBase).toBe("refs/mux-bundle/main"); + expect(runtime.commands).toHaveLength(0); + expect(stderrLines[0]).toContain("using local snapshot refs/mux-bundle/main"); + }); + + it("fetches source branches into explicit remote-tracking refs", async () => { + const runtime = new CommandCaptureSSHRuntime(); + const privateApi = runtime as unknown as FreshWorkspaceSourcePrivateApi; + + await privateApi.fetchOriginTrunk("/remote/src/project/.mux-base.git", "main", noopInitLogger); + + expect(runtime.commands).toContain( + "git fetch origin '+refs/heads/main:refs/remotes/origin/main'" + ); + }); + it("fetches pruneable bundle branches separately from shared tags", async () => { const runtime = new CommandCaptureSSHRuntime(); const layout = createLayout(); diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 1fe8820c52..8314349b44 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -852,6 +852,42 @@ export class SSHRuntime extends RemoteRuntime { return null; } + private async remoteTrackingBranchExists( + baseRepoPathArg: string, + trunkBranch: string, + abortSignal?: AbortSignal + ): Promise { + const originRef = `refs/remotes/origin/${trunkBranch}`; + const check = await execBuffered( + this, + `git -C ${baseRepoPathArg} rev-parse --verify --quiet ${shescape.quote(originRef)}`, + { cwd: "/tmp", timeout: 10, abortSignal } + ); + return check.exitCode === 0; + } + + private async resolveFreshWorkspaceSourceBase( + baseRepoPathArg: string, + trunkBranch: string, + fetchedOrigin: boolean, + fallbackRef: string | null, + initLogger: InitLogger, + abortSignal?: AbortSignal + ): Promise { + if ( + fetchedOrigin && + (await this.remoteTrackingBranchExists(baseRepoPathArg, trunkBranch, abortSignal)) + ) { + return `origin/${trunkBranch}`; + } + + const fallbackBase = fallbackRef ?? "HEAD"; + initLogger.logStderr( + `Note: origin/${trunkBranch} was not available on the remote host; using local snapshot ${fallbackBase}` + ); + return fallbackBase; + } + private async resolveLocalSyncRefManifest(projectPath: string): Promise { try { using proc = execFileAsync("git", ["-C", projectPath, "show-ref", "--heads"]); @@ -1764,8 +1800,8 @@ export class SSHRuntime extends RemoteRuntime { const baseRepoPath = this.getBaseRepoPath(projectPath); const baseRepoPathArg = await this.ensureBaseRepo(projectPath, initLogger, abortSignal); - // Fetch latest from origin in the base repo (best-effort) so new branches - // can start from the latest upstream state. + // Fetch latest from origin in the base repo so an explicit Source branch + // means the upstream branch, not the local snapshot staged in refs/mux-bundle/*. const fetchedOrigin = await this.fetchOriginTrunk( baseRepoPath, trunkBranch, @@ -1774,29 +1810,22 @@ export class SSHRuntime extends RemoteRuntime { nhp ); - // Resolve the bundle's staging ref to use as the local fallback start point. - // The staging ref is refs/mux-bundle/, but the local project's default - // branch may differ from trunkBranch (e.g. "master" vs "main"). + // Resolve the bundle's staging ref to use only as a local fallback start point. + // refs/mux-bundle/* is a transport cache for the user's laptop state; it must + // not override origin/ when the remote source branch is available. const bundleTrunkRef = await this.resolveBundleTrunkRef( baseRepoPathArg, trunkBranch, abortSignal ); - - const shouldUseOrigin = - fetchedOrigin && - bundleTrunkRef != null && - (await this.canFastForwardToOrigin( - baseRepoPath, - bundleTrunkRef, - trunkBranch, - initLogger, - abortSignal - )); - - // When origin is reachable, branch from the fresh remote tracking ref. - // Otherwise, use the bundle's staging ref (or HEAD as last resort). - const newBranchBase = shouldUseOrigin ? `origin/${trunkBranch}` : (bundleTrunkRef ?? "HEAD"); + const newBranchBase = await this.resolveFreshWorkspaceSourceBase( + baseRepoPathArg, + trunkBranch, + fetchedOrigin, + bundleTrunkRef, + initLogger, + abortSignal + ); // git worktree add creates the directory and checks out the branch in one step. // -B creates the branch or resets it to the start point if it already exists @@ -1854,7 +1883,7 @@ export class SSHRuntime extends RemoteRuntime { } /** - * Fetch trunk branch from origin before checkout. + * Fetch trunk branch from origin into its remote-tracking ref before checkout. * Returns true if fetch succeeded (origin is available for branching). */ private async fetchOriginTrunk( @@ -1867,7 +1896,8 @@ export class SSHRuntime extends RemoteRuntime { try { initLogger.logStep(`Fetching latest from origin/${trunkBranch}...`); - const fetchCmd = `${nhp}git fetch origin ${shescape.quote(trunkBranch)}`; + const remoteTrackingRefSpec = `+refs/heads/${trunkBranch}:refs/remotes/origin/${trunkBranch}`; + const fetchCmd = `${nhp}git fetch origin ${shescape.quote(remoteTrackingRefSpec)}`; const fetchStream = await this.exec(fetchCmd, { cwd: workspacePath, timeout: 120, // 2 minutes for network operation diff --git a/src/node/runtime/submoduleSyncRuntimes.test.ts b/src/node/runtime/submoduleSyncRuntimes.test.ts index 1e36de9f53..b3826886f8 100644 --- a/src/node/runtime/submoduleSyncRuntimes.test.ts +++ b/src/node/runtime/submoduleSyncRuntimes.test.ts @@ -170,7 +170,7 @@ describe("workspace checkout orchestration", () => { return Promise.resolve(createExecStream({ stdout: "true\n", exitCode: 0 })); } - if (command.includes("git fetch origin main")) { + if (command.includes("git fetch origin") && command.includes("refs/remotes/origin/main")) { return Promise.resolve(createExecStream({ exitCode: 0 })); }