* 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.
343 lines
9.2 KiB
TypeScript
343 lines
9.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||
import {
|
||
type BranchPickerDetail,
|
||
branchPickerOptionId,
|
||
buildBranchPickerItems,
|
||
buildPickerOptionData,
|
||
defaultBasePickerItem,
|
||
pickerItemToCheckoutRequest,
|
||
type PickerItem,
|
||
} from "./new-workspace-picker-item";
|
||
|
||
const prItem: ForgeSearchItem = {
|
||
kind: "change_request",
|
||
number: 42,
|
||
title: "Add picker",
|
||
url: "https://example.com/pull/42",
|
||
state: "open",
|
||
body: null,
|
||
labels: [],
|
||
baseRefName: "main",
|
||
headRefName: "feature/picker",
|
||
};
|
||
|
||
describe("branchPickerOptionId", () => {
|
||
it("distinguishes a local origin-prefixed branch from the origin ref", () => {
|
||
expect(branchPickerOptionId("refs/heads/origin/main")).not.toBe(
|
||
branchPickerOptionId("refs/remotes/origin/main"),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("pickerItemToCheckoutRequest", () => {
|
||
it("returns undefined for no selection (null)", () => {
|
||
expect(pickerItemToCheckoutRequest(null)).toBeUndefined();
|
||
});
|
||
|
||
it("maps a branch row to branch-off with its exact ref", () => {
|
||
const item: PickerItem = {
|
||
kind: "branch",
|
||
name: "dev",
|
||
refName: "refs/heads/dev",
|
||
accessibilityLabel: "dev, local branch",
|
||
};
|
||
expect(pickerItemToCheckoutRequest(item)).toEqual({
|
||
action: "branch-off",
|
||
refName: "refs/heads/dev",
|
||
});
|
||
});
|
||
|
||
it("maps a github-pr row to checkout using the head ref and pr number", () => {
|
||
const item: PickerItem = {
|
||
kind: "github-pr",
|
||
item: prItem,
|
||
};
|
||
expect(pickerItemToCheckoutRequest(item)).toEqual({
|
||
action: "checkout",
|
||
refName: "feature/picker",
|
||
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
|
||
githubPrNumber: 42,
|
||
});
|
||
});
|
||
|
||
it("handles a github-pr with a null baseRef", () => {
|
||
const item: PickerItem = {
|
||
kind: "github-pr",
|
||
item: {
|
||
...prItem,
|
||
number: 7,
|
||
title: "Orphan branch",
|
||
baseRefName: null,
|
||
headRefName: "orphan",
|
||
},
|
||
};
|
||
expect(pickerItemToCheckoutRequest(item)).toEqual({
|
||
action: "checkout",
|
||
refName: "orphan",
|
||
checkoutSource: { kind: "change_request", forge: "github", number: 7 },
|
||
githubPrNumber: 7,
|
||
});
|
||
});
|
||
|
||
it("does not send the legacy githubPrNumber for non-GitHub change requests", () => {
|
||
const item: PickerItem = {
|
||
kind: "github-pr",
|
||
item: {
|
||
...prItem,
|
||
forge: "gitlab",
|
||
number: 21,
|
||
projectPath: "acme/repo",
|
||
url: "https://gitlab.example.com/acme/repo/-/merge_requests/21",
|
||
},
|
||
};
|
||
expect(pickerItemToCheckoutRequest(item)).toEqual({
|
||
action: "checkout",
|
||
refName: "feature/picker",
|
||
checkoutSource: {
|
||
kind: "change_request",
|
||
forge: "gitlab",
|
||
number: 21,
|
||
projectPath: "acme/repo",
|
||
},
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("buildBranchPickerItems", () => {
|
||
it("keeps a single origin row when local and origin match", () => {
|
||
expect(
|
||
buildBranchPickerItems([
|
||
{
|
||
name: "main",
|
||
committerDate: 10,
|
||
hasLocal: true,
|
||
hasRemote: true,
|
||
localAhead: 0,
|
||
localBehind: 0,
|
||
},
|
||
]),
|
||
).toEqual([
|
||
{
|
||
kind: "branch",
|
||
name: "main",
|
||
refName: "refs/remotes/origin/main",
|
||
accessibilityLabel: "main, origin branch",
|
||
committerDate: 10,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("puts the origin row first and disambiguates the local row when the refs differ", () => {
|
||
expect(
|
||
buildBranchPickerItems([
|
||
{
|
||
name: "main",
|
||
committerDate: 10,
|
||
hasLocal: true,
|
||
hasRemote: true,
|
||
localAhead: 3,
|
||
localBehind: 2,
|
||
},
|
||
]),
|
||
).toEqual([
|
||
{
|
||
kind: "branch",
|
||
name: "main",
|
||
refName: "refs/remotes/origin/main",
|
||
accessibilityLabel: "main, origin branch",
|
||
committerDate: 10,
|
||
},
|
||
{
|
||
kind: "branch",
|
||
name: "main (local)",
|
||
refName: "refs/heads/main",
|
||
divergenceLabel: "+3 −2",
|
||
accessibilityLabel: "main, local branch, 3 commits ahead and 2 behind origin main",
|
||
committerDate: 10,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("uses an exact origin ref for remote-only branches", () => {
|
||
expect(
|
||
buildBranchPickerItems([
|
||
{
|
||
name: "release",
|
||
committerDate: 5,
|
||
hasLocal: false,
|
||
hasRemote: true,
|
||
},
|
||
]),
|
||
).toEqual([
|
||
{
|
||
kind: "branch",
|
||
name: "release",
|
||
refName: "refs/remotes/origin/release",
|
||
accessibilityLabel: "release, origin branch",
|
||
committerDate: 5,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("keeps the plain name for a local-only branch", () => {
|
||
expect(
|
||
buildBranchPickerItems([
|
||
{
|
||
name: "scratch",
|
||
committerDate: 5,
|
||
hasLocal: true,
|
||
hasRemote: false,
|
||
},
|
||
]),
|
||
).toEqual([
|
||
{
|
||
kind: "branch",
|
||
name: "scratch",
|
||
refName: "refs/heads/scratch",
|
||
accessibilityLabel: "scratch, local branch",
|
||
committerDate: 5,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("shows both refs when their divergence is unavailable", () => {
|
||
expect(
|
||
buildBranchPickerItems([
|
||
{
|
||
name: "main",
|
||
committerDate: 10,
|
||
hasLocal: true,
|
||
hasRemote: true,
|
||
},
|
||
]),
|
||
).toEqual([
|
||
{
|
||
kind: "branch",
|
||
name: "main",
|
||
refName: "refs/remotes/origin/main",
|
||
accessibilityLabel: "main, origin branch",
|
||
committerDate: 10,
|
||
},
|
||
{
|
||
kind: "branch",
|
||
name: "main (local)",
|
||
refName: "refs/heads/main",
|
||
accessibilityLabel: "main, local branch",
|
||
committerDate: 10,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("keeps the legacy unqualified row when provenance is unavailable", () => {
|
||
expect(buildBranchPickerItems([{ name: "legacy", committerDate: 1 }])).toEqual([
|
||
{
|
||
kind: "branch",
|
||
name: "legacy",
|
||
refName: "legacy",
|
||
accessibilityLabel: "legacy, branch",
|
||
committerDate: 1,
|
||
},
|
||
]);
|
||
});
|
||
});
|
||
|
||
const mainRow: BranchPickerDetail = {
|
||
name: "main",
|
||
committerDate: 10,
|
||
hasLocal: true,
|
||
hasRemote: true,
|
||
localAhead: 2,
|
||
localBehind: 0,
|
||
};
|
||
|
||
describe("buildPickerOptionData", () => {
|
||
it("marks origin and keeps an ahead local main explicit", () => {
|
||
const baseItem = defaultBasePickerItem({
|
||
currentBranch: "main",
|
||
upstreamRef: "refs/remotes/origin/main",
|
||
});
|
||
const data = buildPickerOptionData({ branchDetails: [mainRow], prItems: [], baseItem });
|
||
|
||
expect(data.options.map((option) => option.label)).toEqual(["main", "main (local)"]);
|
||
expect(data.selectedOptionId).toBe(branchPickerOptionId("refs/remotes/origin/main"));
|
||
});
|
||
|
||
it("adds and disambiguates a fork upstream absent from branch suggestions", () => {
|
||
const baseItem = defaultBasePickerItem({
|
||
currentBranch: "main",
|
||
upstreamRef: "refs/remotes/upstream/main",
|
||
});
|
||
const data = buildPickerOptionData({
|
||
branchDetails: [{ ...mainRow, localAhead: 0, localBehind: 0 }],
|
||
prItems: [],
|
||
baseItem,
|
||
});
|
||
|
||
expect(data.options.map((option) => option.label)).toEqual(["main (upstream)", "main"]);
|
||
expect(data.selectedOptionId).toBe(branchPickerOptionId("refs/remotes/upstream/main"));
|
||
});
|
||
|
||
it("marks a visible row on an old daemon when local and origin are in sync", () => {
|
||
const baseItem = defaultBasePickerItem({ currentBranch: "main" });
|
||
const data = buildPickerOptionData({
|
||
branchDetails: [{ ...mainRow, localAhead: 0, localBehind: 0 }],
|
||
prItems: [],
|
||
baseItem,
|
||
});
|
||
|
||
expect(data.options.map((option) => option.label)).toEqual(["main (local)", "main"]);
|
||
expect(data.selectedOptionId).toBe(branchPickerOptionId("refs/heads/main"));
|
||
});
|
||
|
||
it("keeps an explicit local selection marked", () => {
|
||
const baseItem: PickerItem = {
|
||
kind: "branch",
|
||
name: "main (local)",
|
||
refName: "refs/heads/main",
|
||
accessibilityLabel: "main, local branch",
|
||
};
|
||
const data = buildPickerOptionData({
|
||
branchDetails: [mainRow],
|
||
prItems: [],
|
||
baseItem,
|
||
});
|
||
const selected = data.itemById.get(data.selectedOptionId) ?? null;
|
||
expect(pickerItemToCheckoutRequest(selected)).toEqual({
|
||
action: "branch-off",
|
||
refName: "refs/heads/main",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("defaultBasePickerItem", () => {
|
||
it("uses origin when the current branch tracks origin", () => {
|
||
expect(
|
||
defaultBasePickerItem({
|
||
currentBranch: "main",
|
||
upstreamRef: "refs/remotes/origin/main",
|
||
}),
|
||
).toMatchObject({ refName: "refs/remotes/origin/main", name: "main" });
|
||
});
|
||
|
||
it("uses the exact non-origin upstream", () => {
|
||
expect(
|
||
defaultBasePickerItem({
|
||
currentBranch: "main",
|
||
upstreamRef: "refs/remotes/upstream/main",
|
||
}),
|
||
).toMatchObject({ refName: "refs/remotes/upstream/main", name: "main" });
|
||
});
|
||
|
||
it("keeps old-daemon behavior local", () => {
|
||
expect(defaultBasePickerItem({ currentBranch: "main" })).toMatchObject({
|
||
refName: "refs/heads/main",
|
||
name: "main",
|
||
});
|
||
});
|
||
|
||
it("has no default for detached HEAD", () => {
|
||
expect(defaultBasePickerItem({ currentBranch: null })).toBeNull();
|
||
});
|
||
});
|