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
74 changes: 74 additions & 0 deletions src/node/runtime/SSHRuntime.syncContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ interface SnapshotPrivateApi {
resolveLocalSyncRefManifest(projectPath: string): Promise<string | null>;
}

interface FreshWorkspaceSourcePrivateApi {
resolveFreshWorkspaceSourceBase(
baseRepoPathArg: string,
trunkBranch: string,
fetchedOrigin: boolean,
fallbackRef: string | null,
initLogger: InitLogger,
abortSignal?: AbortSignal
): Promise<string>;
fetchOriginTrunk(
workspacePath: string,
trunkBranch: string,
initLogger: InitLogger,
abortSignal?: AbortSignal,
nhp?: string
): Promise<boolean>;
}

function createLayout(): RemoteProjectLayout {
return {
projectId: "project-id",
Expand Down Expand Up @@ -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();
Expand Down
74 changes: 52 additions & 22 deletions src/node/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,42 @@ export class SSHRuntime extends RemoteRuntime {
return null;
}

private async remoteTrackingBranchExists(
baseRepoPathArg: string,
trunkBranch: string,
abortSignal?: AbortSignal
): Promise<boolean> {
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<string> {
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<string | null> {
try {
using proc = execFileAsync("git", ["-C", projectPath, "show-ref", "--heads"]);
Expand Down Expand Up @@ -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,
Expand All @@ -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/<trunk>, 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/<source> 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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/node/runtime/submoduleSyncRuntimes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}

Expand Down
Loading