From a9b2a7b6edae7650c144dc9b86250f46a450eaa4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 08:02:04 +0100 Subject: [PATCH] Hide local merge for unsupported remote bases --- packages/app/src/git/policy.test.ts | 38 ++++++++++++++++++---------- packages/app/src/git/policy.ts | 13 ++++++---- packages/app/src/git/use-actions.tsx | 12 ++++----- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/packages/app/src/git/policy.test.ts b/packages/app/src/git/policy.test.ts index 74daaed5b4..34f6e93d1d 100644 --- a/packages/app/src/git/policy.test.ts +++ b/packages/app/src/git/policy.test.ts @@ -6,7 +6,7 @@ import { actionsWithoutPrimary, buildGitActions, isCurrentBranchBase, - isCurrentBranchLocalMergeTarget, + canMergeIntoLocalBase, type BuildGitActionsInput, } from "./policy"; import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability"; @@ -78,7 +78,7 @@ function createInput( hasRemote: false, isPaseoOwnedWorktree: false, isOnBaseBranch: true, - isOnLocalMergeTarget: false, + canMergeLocally: true, hasUncommittedChanges: false, baseRefAvailable: true, baseRefLabel: "main", @@ -221,9 +221,9 @@ describe("git-actions-policy", () => { expect(isCurrentBranchBase("release", "release")).toBe(true); expect(isCurrentBranchBase("release", "origin/release")).toBe(true); expect(isCurrentBranchBase(null, "refs/heads/release")).toBe(false); - expect(isCurrentBranchLocalMergeTarget("release", "refs/heads/release")).toBe(true); - expect(isCurrentBranchLocalMergeTarget("feature", "refs/remotes/origin/release")).toBe(false); - expect(isCurrentBranchLocalMergeTarget("release", "refs/remotes/upstream/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", () => { @@ -231,10 +231,7 @@ describe("git-actions-policy", () => { createInput({ isPaseoOwnedWorktree: true, isOnBaseBranch: isCurrentBranchBase("release", "refs/heads/origin/release"), - isOnLocalMergeTarget: isCurrentBranchLocalMergeTarget( - "release", - "refs/heads/origin/release", - ), + canMergeLocally: canMergeIntoLocalBase("release", "refs/heads/origin/release"), baseRefLabel: "origin/release", aheadCount: 1, shipDefault: "merge", @@ -243,15 +240,30 @@ describe("git-actions-policy", () => { 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, - isOnLocalMergeTarget: isCurrentBranchLocalMergeTarget( - "release", - "refs/remotes/origin/release", - ), + canMergeLocally: canMergeIntoLocalBase("release", "refs/remotes/origin/release"), baseRefLabel: "release", aheadCount: 1, behindBaseCount: 1, diff --git a/packages/app/src/git/policy.ts b/packages/app/src/git/policy.ts index 9c5dbf33f8..9f18e65755 100644 --- a/packages/app/src/git/policy.ts +++ b/packages/app/src/git/policy.ts @@ -61,13 +61,16 @@ export function isCurrentBranchBase( return currentBranch === baseRef.replace(/^origin\//, ""); } -export function isCurrentBranchLocalMergeTarget( +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; + return currentBranch !== localTarget; } export interface BuildGitActionsInput { @@ -88,7 +91,7 @@ export interface BuildGitActionsInput { hasRemote: boolean; isPaseoOwnedWorktree: boolean; isOnBaseBranch: boolean; - isOnLocalMergeTarget: boolean; + canMergeLocally: boolean; hasUncommittedChanges: boolean; baseRefAvailable: boolean; baseRefLabel: string; @@ -370,7 +373,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null { if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) { return "pr"; } - if (!input.isOnBaseBranch && !input.isOnLocalMergeTarget && input.aheadCount > 0) { + if (!input.isOnBaseBranch && input.canMergeLocally && input.aheadCount > 0) { return "merge-branch"; } if (!input.isOnBaseBranch && canMergeFromBase(input)) { @@ -401,7 +404,7 @@ function getPullRequestActionIds(filter: { function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] { return [ "merge-from-base", - ...(!input.isOnLocalMergeTarget ? (["merge-branch"] as const) : []), + ...(input.canMergeLocally ? (["merge-branch"] as const) : []), ...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }), ]; } diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index ea9de74511..13f7587623 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -13,7 +13,7 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us import { buildGitActions, isCurrentBranchBase, - isCurrentBranchLocalMergeTarget, + canMergeIntoLocalBase, narrowPullRequestState, type BuildGitActionsInput, type GitAction, @@ -131,7 +131,7 @@ interface DerivedGitActionsState { hasRemote: boolean; isPaseoOwnedWorktree: boolean; isOnBaseBranch: boolean; - isOnLocalMergeTarget: boolean; + canMergeLocally: boolean; shouldPromoteArchive: boolean; } @@ -182,7 +182,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio hasRemote: gitStatus?.hasRemote ?? false, isPaseoOwnedWorktree, isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef), - isOnLocalMergeTarget: isCurrentBranchLocalMergeTarget(gitStatus?.currentBranch, baseRef), + canMergeLocally: canMergeIntoLocalBase(gitStatus?.currentBranch, baseRef), shouldPromoteArchive: computeShouldPromoteArchive({ hasUncommittedChanges, postShipArchiveSuggested, @@ -669,7 +669,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, - isOnLocalMergeTarget, + canMergeLocally, shouldPromoteArchive, } = derived; @@ -700,7 +700,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, - isOnLocalMergeTarget, + canMergeLocally, hasUncommittedChanges, baseRefAvailable: Boolean(baseRef), baseRefLabel, @@ -818,7 +818,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use behindBaseCount, isPaseoOwnedWorktree, isOnBaseBranch, - isOnLocalMergeTarget, + canMergeLocally, githubFeaturesEnabled, forge, githubAutoMergeActionsEnabled,