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
@@ -61,7 +61,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { getForgePresentation } from "@/git/forge";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
@@ -90,12 +89,14 @@ import {
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
import {
branchPickerOptionId,
buildBranchPickerItems,
resolveCheckoutRequest,
buildPickerOptionData,
defaultBasePickerItem,
pickerItemLabel,
pickerItemToCheckoutRequest,
type BranchPickerDetail,
type PickerCheckoutRequest,
type PickerItem,
type PickerOptionData,
} from "./new-workspace-picker-item";
import {
clearPickerPrAttachmentForTargetChange,
@@ -169,12 +170,6 @@ interface NewWorkspaceScreenProps {
draftId?: string;
}
interface PickerOptionData {
options: ComboboxOptionType[];
itemById: Map<string, PickerItem>;
}
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.
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
@@ -475,10 +470,6 @@ function ProjectOptionItem({
);
}
function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`;
}
function NewWorkspacePickerOption({
option,
selected,
@@ -590,57 +581,10 @@ function AddProjectPickerAction({ onPress }: { onPress: () => void }) {
);
}
function formatPrLabel(item: Pick<ForgeSearchItem, "forge" | "number" | "title">): string {
const presentation = getForgePresentation(item.forge ?? "github");
return `${presentation.numberPrefix}${item.number} ${item.title}`;
}
function pickerItemLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function pickerItemTriggerLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function newWorkspaceHostOptionTestID(serverId: string): string {
return `new-workspace-host-picker-option-${serverId}`;
}
function computePickerOptionData(
branchDetails: readonly BranchPickerDetail[],
prItems: ReadonlyArray<ForgeSearchItem>,
): PickerOptionData {
const idMap = new Map<string, PickerItem>();
interface TimedOption {
option: ComboboxOptionType;
timestamp: number;
}
const timedOptions: TimedOption[] = [];
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, branch);
timedOptions.push({ option, timestamp: branch.committerDate ?? 0 });
}
for (const pr of prItems) {
if (!pr.headRefName) continue;
const id = prOptionId(pr.number);
const option = { id, label: formatPrLabel(pr) };
idMap.set(id, { kind: "github-pr", item: pr });
const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0;
const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000);
timedOptions.push({ option, timestamp });
}
timedOptions.sort((a, b) => b.timestamp - a.timestamp);
return { options: timedOptions.map((t) => t.option), itemById: idMap };
}
function IsolationPickerTrigger({
pickerAnchorRef,
onPress,
@@ -1655,7 +1599,6 @@ export function NewWorkspaceScreen({
cwd: selectedSourceDirectory ?? "",
});
const currentBranch = checkoutStatus?.currentBranch ?? null;
const worktreeSupport = selectedProject
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
: "unsupported";
@@ -1709,25 +1652,23 @@ export function NewWorkspaceScreen({
return githubPrSearchQuery.data?.items ?? [];
}, [forgeSearchAuthenticated, githubPrSearchQuery.data?.items]);
const { options, itemById }: PickerOptionData = useMemo(
() => computePickerOptionData(branchDetails, prItems),
[branchDetails, prItems],
const baseItem = useMemo(
() => selectedItem ?? (checkoutStatus ? defaultBasePickerItem(checkoutStatus) : null),
[checkoutStatus, selectedItem],
);
const { options, itemById, selectedOptionId }: PickerOptionData = useMemo(
() =>
buildPickerOptionData({
branchDetails,
prItems,
baseItem,
}),
[baseItem, branchDetails, prItems],
);
const triggerLabel = useMemo(() => {
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
return currentBranch ?? "main";
}, [currentBranch, selectedItem]);
const selectedOptionId = useMemo(() => {
if (!selectedItem) {
if (!currentBranch) return "";
const exactLocalId = branchPickerOptionId(`refs/heads/${currentBranch}`);
return itemById.has(exactLocalId) ? exactLocalId : branchPickerOptionId(currentBranch);
}
return selectedItem.kind === "branch"
? branchPickerOptionId(selectedItem.refName)
: prOptionId(selectedItem.item.number);
}, [currentBranch, itemById, selectedItem]);
const displayItem = itemById.get(selectedOptionId);
return displayItem ? pickerItemLabel(displayItem) : "main";
}, [itemById, selectedOptionId]);
const selectPickerItem = useCallback(
(item: PickerItem) => {
const nextAttachments = syncPickerPrAttachment({
@@ -1929,15 +1870,17 @@ export function NewWorkspaceScreen({
}
const connectedClient = withConnectedClient();
const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree";
const checkoutRequest = createsWorktree
? resolveCheckoutRequest(
selectedItem,
await ensureCheckoutStatus({
queryClient,
client: connectedClient,
serverId: selectedServerId,
cwd: selectedSourceDirectory,
}),
const checkoutStatusForCreate = createsWorktree
? await ensureCheckoutStatus({
queryClient,
client: connectedClient,
serverId: selectedServerId,
cwd: selectedSourceDirectory,
})
: null;
const checkoutRequest = checkoutStatusForCreate
? pickerItemToCheckoutRequest(
selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate),
)
: undefined;
const normalizedWorkspace = supportsWorkspaceMultiplicity