Fix/local branch worktree base (#2328)
* Add automatic plus branch maintenance workflow * Build plus artifacts for desktop and CLI/server * Document and summarize plus artifact workflow * Document Apple signing and pin plus workflow actions * Disable Linux plus desktop artifact * Publish plus CLI packages to GitHub Packages * Fix GitHub Packages publish paths * Fix plus mise install summary package name * Disable macOS x64 plus desktop artifact * Tag plus npm packages as latest for mise * fix(worktrees): respect selected local base ref * chore(ci): remove fork-only plus workflow * fix(worktrees): preserve exact branch picker refs --------- Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
This commit is contained in:
co-authored by
Matt Cowger
parent
b0a5c09ace
commit
76abc50f82
@@ -244,6 +244,7 @@ export interface ComboboxItemProps {
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
|
||||
elevated?: boolean;
|
||||
onPress: () => void;
|
||||
@@ -259,6 +260,7 @@ export function ComboboxItem({
|
||||
selected,
|
||||
active,
|
||||
disabled,
|
||||
accessibilityLabel,
|
||||
elevated,
|
||||
onPress,
|
||||
testID,
|
||||
@@ -301,11 +303,11 @@ export function ComboboxItem({
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={itemPressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
>
|
||||
{leadingContent}
|
||||
<View style={itemContentStyle}>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
|
||||
import {
|
||||
branchPickerOptionId,
|
||||
buildBranchPickerItems,
|
||||
pickerItemToCheckoutRequest,
|
||||
resolveCheckoutRequest,
|
||||
type PickerItem,
|
||||
@@ -19,16 +21,29 @@ const prItem: ForgeSearchItem = {
|
||||
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 the branch name", () => {
|
||||
const item: PickerItem = { kind: "branch", name: "dev" };
|
||||
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: "dev",
|
||||
refName: "refs/heads/dev",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +103,124 @@ describe("pickerItemToCheckoutRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBranchPickerItems", () => {
|
||||
it("hides the origin duplicate 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/heads/main",
|
||||
accessibilityLabel: "main, local branch, up to date with origin main",
|
||||
committerDate: 10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates compact local and origin rows 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/heads/main",
|
||||
divergenceLabel: "+3 −2",
|
||||
accessibilityLabel: "main, local branch, 3 commits ahead and 2 behind origin main",
|
||||
committerDate: 10,
|
||||
},
|
||||
{
|
||||
kind: "branch",
|
||||
name: "origin/main",
|
||||
refName: "refs/remotes/origin/main",
|
||||
divergenceLabel: "+2 −3",
|
||||
accessibilityLabel: "origin main, 2 commits ahead and 3 behind local 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: "origin/release",
|
||||
refName: "refs/remotes/origin/release",
|
||||
accessibilityLabel: "origin release, origin 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/heads/main",
|
||||
accessibilityLabel: "main, local branch",
|
||||
committerDate: 10,
|
||||
},
|
||||
{
|
||||
kind: "branch",
|
||||
name: "origin/main",
|
||||
refName: "refs/remotes/origin/main",
|
||||
accessibilityLabel: "origin main, origin 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCheckoutRequest", () => {
|
||||
const checkoutStatus = {
|
||||
currentBranch: "feature/current",
|
||||
@@ -102,10 +235,18 @@ describe("resolveCheckoutRequest", () => {
|
||||
|
||||
it("prefers an explicit picker selection", () => {
|
||||
expect(
|
||||
resolveCheckoutRequest({ kind: "branch", name: "feature/picked" }, checkoutStatus),
|
||||
resolveCheckoutRequest(
|
||||
{
|
||||
kind: "branch",
|
||||
name: "feature/picked",
|
||||
refName: "refs/heads/feature/picked",
|
||||
accessibilityLabel: "feature/picked, local branch",
|
||||
},
|
||||
checkoutStatus,
|
||||
),
|
||||
).toEqual({
|
||||
action: "branch-off",
|
||||
refName: "feature/picked",
|
||||
refName: "refs/heads/feature/picked",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,24 @@ import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
|
||||
|
||||
export interface BranchPickerDetail {
|
||||
name: string;
|
||||
committerDate: number;
|
||||
hasLocal?: boolean;
|
||||
hasRemote?: boolean;
|
||||
localAhead?: number;
|
||||
localBehind?: number;
|
||||
}
|
||||
|
||||
export type PickerItem =
|
||||
| { kind: "branch"; name: string }
|
||||
| {
|
||||
kind: "branch";
|
||||
name: string;
|
||||
refName: string;
|
||||
accessibilityLabel: string;
|
||||
divergenceLabel?: string;
|
||||
committerDate?: number;
|
||||
}
|
||||
| {
|
||||
kind: "github-pr";
|
||||
item: ForgeSearchItem;
|
||||
@@ -14,13 +30,111 @@ export type PickerCheckoutRequest = Pick<
|
||||
"action" | "refName" | "checkoutSource" | "githubPrNumber"
|
||||
>;
|
||||
|
||||
const BRANCH_OPTION_PREFIX = "branch:";
|
||||
|
||||
export function branchPickerOptionId(refName: string): string {
|
||||
return `${BRANCH_OPTION_PREFIX}${refName}`;
|
||||
}
|
||||
|
||||
function divergenceLabel(ahead: number, behind: number): string | undefined {
|
||||
const parts = [ahead > 0 ? `+${ahead}` : null, behind > 0 ? `−${behind}` : null].filter(
|
||||
(part): part is string => part !== null,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
function commitCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "commit" : "commits"}`;
|
||||
}
|
||||
|
||||
function divergenceAccessibility(ahead: number, behind: number, counterpart: string): string {
|
||||
if (ahead === 0 && behind === 0) {
|
||||
return `up to date with ${counterpart}`;
|
||||
}
|
||||
if (ahead > 0 && behind > 0) {
|
||||
return `${commitCount(ahead)} ahead and ${behind} behind ${counterpart}`;
|
||||
}
|
||||
if (ahead > 0) {
|
||||
return `${commitCount(ahead)} ahead of ${counterpart}`;
|
||||
}
|
||||
return `${commitCount(behind)} behind ${counterpart}`;
|
||||
}
|
||||
|
||||
export function buildBranchPickerItems(details: readonly BranchPickerDetail[]): PickerItem[] {
|
||||
const items: PickerItem[] = [];
|
||||
|
||||
for (const detail of details) {
|
||||
const hasKnownProvenance = detail.hasLocal !== undefined || detail.hasRemote !== undefined;
|
||||
if (!hasKnownProvenance) {
|
||||
items.push({
|
||||
kind: "branch",
|
||||
name: detail.name,
|
||||
refName: detail.name,
|
||||
accessibilityLabel: `${detail.name}, branch`,
|
||||
committerDate: detail.committerDate,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasLocal = detail.hasLocal === true;
|
||||
const hasRemote = detail.hasRemote === true;
|
||||
const localAhead = detail.localAhead;
|
||||
const localBehind = detail.localBehind;
|
||||
const hasDivergence =
|
||||
hasLocal && hasRemote && localAhead !== undefined && localBehind !== undefined;
|
||||
|
||||
if (hasLocal) {
|
||||
const localItem: Extract<PickerItem, { kind: "branch" }> = {
|
||||
kind: "branch",
|
||||
name: detail.name,
|
||||
refName: `refs/heads/${detail.name}`,
|
||||
accessibilityLabel: `${detail.name}, local branch`,
|
||||
committerDate: detail.committerDate,
|
||||
};
|
||||
if (hasDivergence) {
|
||||
localItem.divergenceLabel = divergenceLabel(localAhead, localBehind);
|
||||
localItem.accessibilityLabel += `, ${divergenceAccessibility(
|
||||
localAhead,
|
||||
localBehind,
|
||||
`origin ${detail.name}`,
|
||||
)}`;
|
||||
}
|
||||
items.push(localItem);
|
||||
}
|
||||
|
||||
const refsDiffer = hasDivergence && (localAhead > 0 || localBehind > 0);
|
||||
if (hasRemote && (!hasLocal || !hasDivergence || refsDiffer)) {
|
||||
const remoteAhead = hasDivergence ? localBehind : 0;
|
||||
const remoteBehind = hasDivergence ? localAhead : 0;
|
||||
const remoteItem: Extract<PickerItem, { kind: "branch" }> = {
|
||||
kind: "branch",
|
||||
name: `origin/${detail.name}`,
|
||||
refName: `refs/remotes/origin/${detail.name}`,
|
||||
accessibilityLabel: `origin ${detail.name}, origin branch`,
|
||||
committerDate: detail.committerDate,
|
||||
};
|
||||
if (hasDivergence) {
|
||||
remoteItem.divergenceLabel = divergenceLabel(remoteAhead, remoteBehind);
|
||||
remoteItem.accessibilityLabel = `origin ${detail.name}, ${divergenceAccessibility(
|
||||
remoteAhead,
|
||||
remoteBehind,
|
||||
`local ${detail.name}`,
|
||||
)}`;
|
||||
}
|
||||
items.push(remoteItem);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function pickerItemToCheckoutRequest(
|
||||
item: PickerItem | null,
|
||||
): PickerCheckoutRequest | undefined {
|
||||
if (!item) return undefined;
|
||||
switch (item.kind) {
|
||||
case "branch":
|
||||
return { action: "branch-off", refName: item.name };
|
||||
return { action: "branch-off", refName: item.refName };
|
||||
case "github-pr": {
|
||||
const headRefName = item.item.headRefName?.trim();
|
||||
const forge = item.item.forge ?? "github";
|
||||
|
||||
@@ -65,7 +65,12 @@ describe("syncPickerPrAttachment", () => {
|
||||
const issue = issueAttachment(44);
|
||||
const result = syncPickerPrAttachment({
|
||||
attachments: [issue],
|
||||
item: { kind: "branch", name: "dev" },
|
||||
item: {
|
||||
kind: "branch",
|
||||
name: "dev",
|
||||
refName: "refs/heads/dev",
|
||||
accessibilityLabel: "dev, local branch",
|
||||
},
|
||||
});
|
||||
expect(result).toEqual([issue]);
|
||||
});
|
||||
@@ -85,7 +90,12 @@ describe("syncPickerPrAttachment", () => {
|
||||
const issue = issueAttachment(44);
|
||||
const result = syncPickerPrAttachment({
|
||||
attachments: [issue, prAttachment(pr, "new-workspace-picker")],
|
||||
item: { kind: "branch", name: "dev" },
|
||||
item: {
|
||||
kind: "branch",
|
||||
name: "dev",
|
||||
refName: "refs/heads/dev",
|
||||
accessibilityLabel: "dev, local branch",
|
||||
},
|
||||
});
|
||||
expect(result).toEqual([issue]);
|
||||
});
|
||||
@@ -182,7 +192,12 @@ describe("reducePickerSelection", () => {
|
||||
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||
const branchSelected = reducePickerSelection(detected, {
|
||||
type: "picker-selected",
|
||||
item: { kind: "branch", name: "main" },
|
||||
item: {
|
||||
kind: "branch",
|
||||
name: "main",
|
||||
refName: "refs/heads/main",
|
||||
accessibilityLabel: "main, local branch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -205,7 +220,12 @@ describe("reducePickerSelection", () => {
|
||||
it("lets a newly detected PR replace an earlier explicit branch", () => {
|
||||
const branchSelected = reducePickerSelection(initialPickerSelectionState, {
|
||||
type: "picker-selected",
|
||||
item: { kind: "branch", name: "main" },
|
||||
item: {
|
||||
kind: "branch",
|
||||
name: "main",
|
||||
refName: "refs/heads/main",
|
||||
accessibilityLabel: "main, local branch",
|
||||
},
|
||||
});
|
||||
const detected = reducePickerSelection(branchSelected, { type: "pr-detected" });
|
||||
const pr = { kind: "github-pr" as const, item: makePrItem(101, "A") };
|
||||
|
||||
@@ -90,7 +90,10 @@ import {
|
||||
remapDraftCwdToWorkspace,
|
||||
} from "./new-workspace-fork-context";
|
||||
import {
|
||||
branchPickerOptionId,
|
||||
buildBranchPickerItems,
|
||||
resolveCheckoutRequest,
|
||||
type BranchPickerDetail,
|
||||
type PickerCheckoutRequest,
|
||||
type PickerItem,
|
||||
} from "./new-workspace-picker-item";
|
||||
@@ -171,7 +174,6 @@ interface PickerOptionData {
|
||||
itemById: Map<string, PickerItem>;
|
||||
}
|
||||
|
||||
const BRANCH_OPTION_PREFIX = "branch:";
|
||||
const PR_OPTION_PREFIX = "github-pr:";
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
// Stable reference so the keyboard-action handler doesn't re-register each render.
|
||||
@@ -327,6 +329,8 @@ function PickerOptionItem({
|
||||
disabled,
|
||||
onPress,
|
||||
isBranch,
|
||||
trailingLabel,
|
||||
accessibilityLabel,
|
||||
iconColor,
|
||||
iconSize,
|
||||
}: {
|
||||
@@ -338,6 +342,8 @@ function PickerOptionItem({
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
isBranch: boolean;
|
||||
trailingLabel?: string;
|
||||
accessibilityLabel?: string;
|
||||
iconColor: string;
|
||||
iconSize: number;
|
||||
}) {
|
||||
@@ -353,6 +359,11 @@ function PickerOptionItem({
|
||||
),
|
||||
[isBranch, iconSize, iconColor],
|
||||
);
|
||||
const trailingSlot = useMemo(
|
||||
() =>
|
||||
trailingLabel ? <Text style={styles.refDivergenceLabel}>{trailingLabel}</Text> : undefined,
|
||||
[trailingLabel],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={testID}
|
||||
@@ -363,6 +374,8 @@ function PickerOptionItem({
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -464,10 +477,6 @@ function ProjectOptionItem({
|
||||
);
|
||||
}
|
||||
|
||||
function branchOptionId(name: string): string {
|
||||
return `${BRANCH_OPTION_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
function prOptionId(number: number): string {
|
||||
return `${PR_OPTION_PREFIX}${number}`;
|
||||
}
|
||||
@@ -511,6 +520,8 @@ function NewWorkspacePickerOption({
|
||||
disabled={isPending}
|
||||
onPress={onPress}
|
||||
isBranch={isBranch}
|
||||
trailingLabel={isBranch ? item.divergenceLabel : undefined}
|
||||
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
@@ -599,7 +610,7 @@ function newWorkspaceHostOptionTestID(serverId: string): string {
|
||||
}
|
||||
|
||||
function computePickerOptionData(
|
||||
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
|
||||
branchDetails: readonly BranchPickerDetail[],
|
||||
prItems: ReadonlyArray<ForgeSearchItem>,
|
||||
): PickerOptionData {
|
||||
const idMap = new Map<string, PickerItem>();
|
||||
@@ -610,11 +621,12 @@ function computePickerOptionData(
|
||||
}
|
||||
const timedOptions: TimedOption[] = [];
|
||||
|
||||
for (const branch of branchDetails) {
|
||||
const id = branchOptionId(branch.name);
|
||||
for (const branch of buildBranchPickerItems(branchDetails)) {
|
||||
if (branch.kind !== "branch") continue;
|
||||
const id = branchPickerOptionId(branch.refName);
|
||||
const option = { id, label: branch.name };
|
||||
idMap.set(id, { kind: "branch", name: branch.name });
|
||||
timedOptions.push({ option, timestamp: branch.committerDate });
|
||||
idMap.set(id, branch);
|
||||
timedOptions.push({ option, timestamp: branch.committerDate ?? 0 });
|
||||
}
|
||||
|
||||
for (const pr of prItems) {
|
||||
@@ -735,10 +747,8 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
|
||||
}
|
||||
|
||||
function normalizeBranchDetails(
|
||||
data:
|
||||
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
|
||||
| undefined,
|
||||
): Array<{ name: string; committerDate: number }> {
|
||||
data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined,
|
||||
): BranchPickerDetail[] {
|
||||
const details = data?.branchDetails;
|
||||
if (details && details.length > 0) return details;
|
||||
const names = data?.branches ?? [];
|
||||
@@ -1711,11 +1721,15 @@ export function NewWorkspaceScreen({
|
||||
}, [currentBranch, selectedItem]);
|
||||
|
||||
const selectedOptionId = useMemo(() => {
|
||||
if (!selectedItem) return "";
|
||||
if (!selectedItem) {
|
||||
if (!currentBranch) return "";
|
||||
const exactLocalId = branchPickerOptionId(`refs/heads/${currentBranch}`);
|
||||
return itemById.has(exactLocalId) ? exactLocalId : branchPickerOptionId(currentBranch);
|
||||
}
|
||||
return selectedItem.kind === "branch"
|
||||
? branchOptionId(selectedItem.name)
|
||||
? branchPickerOptionId(selectedItem.refName)
|
||||
: prOptionId(selectedItem.item.number);
|
||||
}, [selectedItem]);
|
||||
}, [currentBranch, itemById, selectedItem]);
|
||||
const selectPickerItem = useCallback(
|
||||
(item: PickerItem) => {
|
||||
const nextAttachments = syncPickerPrAttachment({
|
||||
@@ -2280,6 +2294,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
refDivergenceLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
badgeIconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
|
||||
Reference in New Issue
Block a user