Default new worktrees to the upstream branch (#2848)

* fix(workspaces): default new worktrees to upstream refs

Use the daemon-resolved upstream as the untouched starting ref so unpushed local commits do not leak into new workspaces. Preserve exact base identity for fork remotes while keeping local-first behavior across version drift.

* fix(workspaces): preserve exact base refs in git actions

Resolve accepted display aliases back to the stored ref before comparisons and updates. Fail closed when a non-origin remote base has no explicit mutable merge target.

* fix(workspaces): reject conflicting qualified base refs

Display aliases remain accepted for compatibility, but a qualified ref carries remote identity and must not be canonicalized to a different stored upstream.

* test(server): make upstream fixtures portable

Keep the checkout-status fixture aligned with the optional upstream field, and select main explicitly when cloning a bare test remote whose default HEAD varies by platform.

* fix(workspaces): preserve exact upstream refs

Qualified remote refs now fail closed when pruned instead of switching lineage. Metadata accepts Git-valid upstream names rather than applying a narrower character whitelist.

* fix(workspaces): preserve qualified local base refs

Local tracking branches are exact upstream identities too. Keep heuristics only for legacy unqualified metadata so origin cannot replace a stored refs/heads base.
This commit is contained in:
Mohamed Boudra
2026-08-04 09:01:38 +02:00
committed by GitHub
parent 74dea38456
commit fb657ebc20
17 changed files with 1028 additions and 308 deletions
+148 -1
View File
@@ -24,6 +24,10 @@ import {
openProjectViaDaemon,
openStartingRefPicker,
pasteGithubPrUrl,
captureStartingRefPicker,
expectStartingRefRows,
startingRefRow,
submitNewWorkspaceEmpty,
searchAndSelectBranchInPicker,
selectBranchInPicker,
selectGitHubPrInPicker,
@@ -31,7 +35,14 @@ import {
selectWorkspaceIsolation,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "../support/helpers/workspace";
import {
commitLocalOnly,
createTempGitRepo,
readRepoRef,
readWorktreeBaseMetadata,
readWorktreeBranchInfo,
trackForkUpstream,
} from "../support/helpers/workspace";
import {
createLocalGithubPrFixture,
cloneGithubRepoDefaultBranchOnly,
@@ -743,6 +754,142 @@ test.describe("New workspace flow", () => {
}
});
// The starting ref the daemon actually cuts from is the thing that broke: the picker said
// one ref and the worktree was created from another. Every assertion here reads the
// created worktree's commits or its recorded base, never the trigger text alone.
test.describe("default starting ref", () => {
async function openWorktreeComposerForRepo(
page: import("@playwright/test").Page,
repoPath: string,
) {
const openedProject = await openProjectViaDaemon(client, repoPath);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
return openedProject;
}
async function createWorktreeAndRead(
page: import("@playwright/test").Page,
openedProject: Awaited<ReturnType<typeof openProjectViaDaemon>>,
) {
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
return {
...createdWorkspace,
branchInfo: await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceDirectory,
}),
};
}
test("branches off the upstream when the local branch is ahead and the picker is untouched", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("ref-default-ahead-", { withRemote: true });
try {
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
commitLocalOnly(tempRepo.path, "one");
const localHead = commitLocalOnly(tempRepo.path, "two");
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
const created = await createWorktreeAndRead(page, openedProject);
expect(created.branchInfo.hasAncestor(originHead)).toBe(true);
expect(created.branchInfo.hasAncestor(localHead)).toBe(false);
} finally {
await tempRepo.cleanup();
}
});
test("branches off the local ref when the local row is chosen explicitly", async ({
page,
}, testInfo) => {
const tempRepo = await createTempGitRepo("ref-default-local-pick-", { withRemote: true });
try {
commitLocalOnly(tempRepo.path, "one");
const localHead = commitLocalOnly(tempRepo.path, "two");
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
await openStartingRefPicker(page);
await expectStartingRefRows(page, [
"main, origin branch",
"main, local branch, 2 commits ahead of origin main",
]);
const screenshotPath = testInfo.outputPath("ref-picker-local-ahead.png");
await captureStartingRefPicker(page, screenshotPath);
await testInfo.attach("Ref picker: local ahead of upstream", {
path: screenshotPath,
contentType: "image/png",
});
await startingRefRow(page, "main, local branch, 2 commits ahead of origin main").click();
await expectPickerSelected(page, "main (local)");
const created = await createWorktreeAndRead(page, openedProject);
expect(created.branchInfo.hasAncestor(localHead)).toBe(true);
} finally {
await tempRepo.cleanup();
}
});
test("branches off a fork's upstream remote and records the branch name", async ({
page,
}, testInfo) => {
const tempRepo = await createTempGitRepo("ref-default-fork-", { withRemote: true });
try {
const upstreamHead = await trackForkUpstream(tempRepo.path);
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
await openStartingRefPicker(page);
// Branch suggestions only know about origin, so the upstream the fork actually
// tracks gets its own row rather than silently sharing origin's. Two rows reading
// "main" is the ambiguity this whole change exists to remove.
await expectStartingRefRows(page, [
"main (upstream), upstream branch",
"main, origin branch",
]);
await expectPickerSelected(page, "main (upstream)");
const screenshotPath = testInfo.outputPath("ref-picker-fork.png");
await captureStartingRefPicker(page, screenshotPath);
await testInfo.attach("Ref picker: fork tracking upstream/main", {
path: screenshotPath,
contentType: "image/png",
});
await closeBranchPicker(page);
const created = await createWorktreeAndRead(page, openedProject);
expect(created.branchInfo.hasAncestor(upstreamHead)).toBe(true);
expect(upstreamHead).not.toBe(originHead);
// The name is what the UI shows; the ref is what resolves back to this commit.
expect(await readWorktreeBaseMetadata(created.workspaceDirectory)).toEqual({
baseRefName: "main",
baseRef: "refs/remotes/upstream/main",
});
} finally {
await tempRepo.cleanup();
}
});
});
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
page,
}) => {