Change a worktree workspace base from Changes (#8642)

Change a worktree workspace's base from the Changes pane. The choice persists and diffs, counts, commits and Git actions follow it.

Supersedes #6872.

Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
This commit is contained in:
Mohamed Boudra
2026-11-05 22:30:14 +07:00
committed by GitHub
co-authored by Tianyu Yang
parent e9f897a66a
commit 3ed57e9dd7
41 changed files with 1192 additions and 46 deletions
@@ -25,7 +25,7 @@ export async function fetchCheckoutStatus({
serverId: string;
cwd: string;
}): Promise<CheckoutStatusPayload> {
const payload = await client.getCheckoutStatus(cwd);
const payload = normalizeCheckoutBaseRef(await client.getCheckoutStatus(cwd));
return payload;
}
@@ -56,7 +56,7 @@ export function applyCheckoutStatusUpdateFromEvent({
serverId: string;
message: CheckoutStatusUpdate;
}): void {
const { payload } = message;
const payload = normalizeCheckoutBaseRef(message.payload);
const prStatus = payload.prStatus
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
@@ -88,6 +88,13 @@ export function applyCheckoutStatusUpdateFromEvent({
}
}
function normalizeCheckoutBaseRef<T extends CheckoutStatusPayload>(payload: T): T {
// COMPAT(workspaceBaseBranch): hosts <= v0.11.2 only send the display ref; remove after 2027-10-14.
return !payload.isGit || payload.baseRefExact === undefined
? payload
: { ...payload, baseRef: payload.baseRefExact };
}
// requestId changes on every emission and carries no PR state.
function prStatusWithoutVolatileFields(
prStatus: CheckoutPrStatusPayload,
+29
View File
@@ -19,6 +19,7 @@ import type { Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
ArrowRight,
ChevronDown,
Columns2,
ExternalLink,
@@ -61,6 +62,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
import type { GitActions } from "@/git/policy";
import { BranchSwitcher } from "@/components/branch-switcher";
import { useGitActions } from "@/git/use-actions";
import { WorkspaceBaseBranchPicker } from "@/git/workspace-base-branch-picker";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
@@ -201,6 +203,7 @@ const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedArrowRight = withUnistyles(ArrowRight);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
@@ -470,6 +473,8 @@ interface ChangesPullRequestLinkModel extends Pick<PrHint, "forge" | "number" |
}
interface ChangesRepositoryToolbarModel {
baseRef: string | null;
isWorktree: boolean;
branchName: string | null;
cwd: string;
gitActions: GitActions | null;
@@ -496,6 +501,8 @@ interface ChangesHeaderProps {
}
interface BuildChangesHeaderModelInput {
baseRef: string | null;
isWorktree: boolean;
branchName: string | null;
committedDescription?: string;
compact: boolean;
@@ -519,6 +526,8 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
} {
return {
repository: {
baseRef: input.baseRef,
isWorktree: input.isWorktree,
branchName: input.branchName,
cwd: input.cwd,
gitActions: input.compact ? input.gitActions : null,
@@ -629,6 +638,10 @@ function ChangesToolbarTrailing({ children }: { children: ReactNode }) {
return <ToolbarControls style={styles.changesToolbarControls}>{children}</ToolbarControls>;
}
function isManagedWorktree(status: { isPaseoOwnedWorktree?: boolean } | null | undefined): boolean {
return status?.isPaseoOwnedWorktree === true;
}
function ChangesRepositoryToolbar({
compact,
model,
@@ -654,6 +667,18 @@ function ChangesRepositoryToolbar({
isGitCheckout
testID="changes-branch-switcher"
/>
{model.baseRef ? (
<>
<ThemedArrowRight size={14} uniProps={foregroundMutedIconColorMapping} />
<WorkspaceBaseBranchPicker
serverId={model.serverId}
workspaceId={model.workspaceId}
cwd={model.cwd}
baseRef={model.baseRef}
isWorktree={model.isWorktree}
/>
</>
) : null}
</ChangesToolbarLeading>
<ChangesToolbarTrailing>
{model.pullRequest ? (
@@ -1904,6 +1929,8 @@ export function ChangesSurface({
const changesHeaderModel = useMemo(
() =>
buildChangesHeaderModel({
baseRef: baseRef ?? null,
isWorktree: isManagedWorktree(status),
branchName: currentBranchName,
committedDescription: committedDiffDescription,
compact: isMobile,
@@ -1921,6 +1948,8 @@ export function ChangesSurface({
workspaceId,
}),
[
baseRef,
status,
committedDiffDescription,
currentBranchName,
cwd,
+83 -1
View File
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
import {
actionsWithoutPrimary,
buildGitActions,
isCurrentBranchBase,
canMergeIntoLocalBase,
type BuildGitActionsInput,
} from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
@@ -72,6 +78,7 @@ function createInput(
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
canMergeLocally: true,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
@@ -208,6 +215,81 @@ describe("git-actions-policy", () => {
]);
});
it("distinguishes exact local and remote base refs and retains legacy names", () => {
expect(isCurrentBranchBase("release", "refs/heads/release")).toBe(true);
expect(isCurrentBranchBase("release", "refs/remotes/origin/release")).toBe(false);
expect(isCurrentBranchBase("release", "release")).toBe(true);
expect(isCurrentBranchBase("release", "origin/release")).toBe(true);
expect(isCurrentBranchBase(null, "refs/heads/release")).toBe(false);
expect(canMergeIntoLocalBase("release", "refs/heads/release")).toBe(false);
expect(canMergeIntoLocalBase("feature", "refs/remotes/origin/release")).toBe(true);
expect(canMergeIntoLocalBase("release", "refs/remotes/upstream/release")).toBe(false);
});
it("offers local merge when the selected local base starts with origin slash", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: isCurrentBranchBase("release", "refs/heads/origin/release"),
canMergeLocally: canMergeIntoLocalBase("release", "refs/heads/origin/release"),
baseRefLabel: "origin/release",
aheadCount: 1,
shipDefault: "merge",
}),
);
expect(actions.primary?.id).toBe("merge-branch");
});
it("offers update but not local merge for a non-origin remote base", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: false,
canMergeLocally: canMergeIntoLocalBase("feature", "refs/remotes/upstream/release"),
baseRefLabel: "upstream/release",
aheadCount: 1,
behindBaseCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
shipDefault: "merge",
}),
);
expect(actions.primary?.id).toBe("merge-from-base");
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
});
it("does not offer merging into the current local branch when the remote base shares its name", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: false,
canMergeLocally: canMergeIntoLocalBase("release", "refs/remotes/origin/release"),
baseRefLabel: "release",
aheadCount: 1,
behindBaseCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
shipDefault: "merge",
}),
);
expect(actions.primary?.id).toBe("merge-from-base");
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
});
it("offers update from the same-named remote base when it is ahead", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: isCurrentBranchBase("release", "refs/remotes/origin/release"),
baseRefLabel: "release",
behindBaseCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
}),
);
expect(actions.primary?.id).toBe("merge-from-base");
});
it("drops a promoted merge action from the remaining menu actions", () => {
const actions = buildGitActions(
createInput({
+26 -2
View File
@@ -50,6 +50,29 @@ interface GitActionRuntimeState {
handler: () => void;
}
export function isCurrentBranchBase(
currentBranch: string | null | undefined,
baseRef: string | null | undefined,
): boolean {
if (!currentBranch || !baseRef) return false;
if (baseRef.startsWith("refs/heads/")) return baseRef === `refs/heads/${currentBranch}`;
if (baseRef.startsWith("refs/")) return false;
// Older hosts send a display name, which cannot distinguish a local ref from its remote.
return currentBranch === baseRef.replace(/^origin\//, "");
}
export function canMergeIntoLocalBase(
currentBranch: string | null | undefined,
baseRef: string | null | undefined,
): boolean {
if (!currentBranch || !baseRef) return false;
if (baseRef.startsWith("refs/remotes/") && !baseRef.startsWith("refs/remotes/origin/")) {
return false;
}
const localTarget = baseRef.replace(/^(?:refs\/heads\/|refs\/remotes\/origin\/|origin\/)/, "");
return currentBranch !== localTarget;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
@@ -68,6 +91,7 @@ export interface BuildGitActionsInput {
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
canMergeLocally: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
@@ -349,7 +373,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
if (!input.isOnBaseBranch && input.canMergeLocally && input.aheadCount > 0) {
return "merge-branch";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
@@ -380,7 +404,7 @@ function getPullRequestActionIds(filter: {
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
return [
"merge-from-base",
"merge-branch",
...(input.canMergeLocally ? (["merge-branch"] as const) : []),
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
];
}
+11 -4
View File
@@ -12,6 +12,8 @@ import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-st
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import {
buildGitActions,
isCurrentBranchBase,
canMergeIntoLocalBase,
narrowPullRequestState,
type BuildGitActionsInput,
type GitAction,
@@ -116,7 +118,7 @@ interface DeriveGitActionsStateArgs {
hasUncommittedChanges: boolean;
postShipArchiveSuggested: boolean;
isStatusLoading: boolean;
baseRefLabel: string;
baseRef: string | undefined;
}
interface DerivedGitActionsState {
@@ -129,6 +131,7 @@ interface DerivedGitActionsState {
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
canMergeLocally: boolean;
shouldPromoteArchive: boolean;
}
@@ -167,7 +170,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
hasUncommittedChanges,
postShipArchiveSuggested,
isStatusLoading,
baseRefLabel,
baseRef,
} = args;
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
@@ -178,7 +181,8 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
hasPullRequest: Boolean(prStatus?.url),
hasRemote: gitStatus?.hasRemote ?? false,
isPaseoOwnedWorktree,
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef),
canMergeLocally: canMergeIntoLocalBase(gitStatus?.currentBranch, baseRef),
shouldPromoteArchive: computeShouldPromoteArchive({
hasUncommittedChanges,
postShipArchiveSuggested,
@@ -653,7 +657,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasUncommittedChanges,
postShipArchiveSuggested,
isStatusLoading,
baseRefLabel,
baseRef,
});
const {
actionsDisabled,
@@ -665,6 +669,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
canMergeLocally,
shouldPromoteArchive,
} = derived;
@@ -695,6 +700,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
canMergeLocally,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
@@ -812,6 +818,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
canMergeLocally,
githubFeaturesEnabled,
forge,
githubAutoMergeActionsEnabled,
@@ -0,0 +1,179 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Text, View } from "react-native";
import { GitBranch } from "lucide-react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Button } from "@/components/ui/button";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { invalidateCheckoutGitQueriesForClient } from "./query-keys";
interface Props {
serverId: string;
workspaceId?: string | null;
cwd: string;
baseRef: string;
isWorktree: boolean;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedGitBranch = withUnistyles(GitBranch);
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function refLabel(ref: string): string {
return ref.replace(/^refs\/(heads|remotes)\//, "");
}
export function WorkspaceBaseBranchPicker({
serverId,
workspaceId,
cwd,
baseRef,
isWorktree,
}: Props) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
const supported = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceBaseBranch === true,
);
const queryClient = useQueryClient();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const query = useDebouncedValue(search, 200);
const branches = useFetchQuery({
queryKey: ["workspaceBaseBranches", serverId, cwd, query],
queryFn: async () => {
if (!client) throw new Error(t("common.errors.daemonClientUnavailable"));
const result = await client.getBranchSuggestions({
cwd,
refMode: "exact",
query,
limit: 200,
});
if (result.error) throw new Error(result.error);
return result.branches;
},
enabled: open && supported && connected && Boolean(client),
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const mutation = useMutation({
mutationFn: async (ref: string) => {
if (!client || !workspaceId) throw new Error(t("common.errors.daemonClientUnavailable"));
return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref });
},
onSuccess: async () => {
setOpen(false);
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
},
});
const options = useMemo(
() => (branches.data ?? []).map((ref) => ({ id: ref, label: refLabel(ref) })),
[branches.data],
);
const error = mutation.error ?? branches.error;
const title = t("workspace.git.diff.baseBranch.title");
const label = refLabel(baseRef).replace(/^origin\//, "");
const { reset, mutate, isPending } = mutation;
const { refetch, isError } = branches;
const handleOpen = useCallback(() => {
reset();
setSearch("");
setOpen(true);
}, [reset]);
const handleSelect = useCallback(
(ref: string) => {
if (!isPending) mutate(ref);
},
[isPending, mutate],
);
const handleRetry = useCallback(() => {
void refetch();
}, [refetch]);
const branchIcon = useMemo(() => <ThemedGitBranch size={14} uniProps={mutedColor} />, []);
const renderOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
({ option, selected, active, onPress }) => (
<ComboboxItem
label={option.label}
leadingSlot={branchIcon}
selected={selected}
active={active}
onPress={onPress}
disabled={isPending}
/>
),
[isPending, branchIcon],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
{isPending ? <ThemedLoadingSpinner size="small" uniProps={mutedColor} /> : null}
{error ? (
<Text accessibilityRole="alert" testID="workspace-base-branch-error" style={styles.error}>
{error.message}
</Text>
) : null}
{isError ? (
<Button variant="outline" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
) : null}
</View>
),
[t, isPending, error, isError, handleRetry],
);
if (!supported || !isWorktree || !workspaceId) return <Text style={styles.text}>{label}</Text>;
return (
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<ToolbarLabelSelectTrigger
testID="workspace-base-branch-trigger"
label={label}
open={open}
disabled={!connected || mutation.isPending}
accessibilityRole="button"
accessibilityLabel={title}
onPress={handleOpen}
/>
<Combobox
options={options}
value={baseRef}
onSelect={handleSelect}
keepOpenOnSelect
searchable
onSearchQueryChange={setSearch}
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText={branches.isFetching ? t("common.states.loading") : t("branchSwitcher.empty")}
title={title}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
desktopMinWidth={300}
renderOption={renderOption}
footer={error || isPending ? footer : undefined}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
anchor: { flexShrink: 1, minWidth: 0 },
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
}));