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:
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user