Prevent merging a branch into itself

This commit is contained in:
Mohamed Boudra
2026-11-03 07:53:14 +01:00
parent 40ae3afc21
commit 46741065e5
5 changed files with 55 additions and 3 deletions
+26
View File
@@ -6,6 +6,7 @@ import {
actionsWithoutPrimary,
buildGitActions,
isCurrentBranchBase,
isCurrentBranchLocalMergeTarget,
type BuildGitActionsInput,
} from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
@@ -77,6 +78,7 @@ function createInput(
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
isOnLocalMergeTarget: false,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
@@ -219,6 +221,30 @@ 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);
});
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",
),
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", () => {
+15 -2
View File
@@ -61,6 +61,18 @@ export function isCurrentBranchBase(
return currentBranch === baseRef.replace(/^origin\//, "");
}
export function isCurrentBranchLocalMergeTarget(
currentBranch: string | null | undefined,
baseRef: string | null | undefined,
): boolean {
if (!currentBranch || !baseRef) return false;
const localTarget = baseRef
.replace(/^refs\/heads\//, "")
.replace(/^refs\/remotes\/origin\//, "")
.replace(/^origin\//, "");
return currentBranch === localTarget;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
@@ -79,6 +91,7 @@ export interface BuildGitActionsInput {
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
isOnLocalMergeTarget: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
@@ -360,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.isOnLocalMergeTarget && input.aheadCount > 0) {
return "merge-branch";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
@@ -391,7 +404,7 @@ function getPullRequestActionIds(filter: {
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
return [
"merge-from-base",
"merge-branch",
...(!input.isOnLocalMergeTarget ? (["merge-branch"] as const) : []),
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
];
}
+6
View File
@@ -13,6 +13,7 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us
import {
buildGitActions,
isCurrentBranchBase,
isCurrentBranchLocalMergeTarget,
narrowPullRequestState,
type BuildGitActionsInput,
type GitAction,
@@ -130,6 +131,7 @@ interface DerivedGitActionsState {
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
isOnLocalMergeTarget: boolean;
shouldPromoteArchive: boolean;
}
@@ -180,6 +182,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
hasRemote: gitStatus?.hasRemote ?? false,
isPaseoOwnedWorktree,
isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef),
isOnLocalMergeTarget: isCurrentBranchLocalMergeTarget(gitStatus?.currentBranch, baseRef),
shouldPromoteArchive: computeShouldPromoteArchive({
hasUncommittedChanges,
postShipArchiveSuggested,
@@ -666,6 +669,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
isOnLocalMergeTarget,
shouldPromoteArchive,
} = derived;
@@ -696,6 +700,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
isOnLocalMergeTarget,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
@@ -813,6 +818,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
isOnLocalMergeTarget,
githubFeaturesEnabled,
forge,
githubAutoMergeActionsEnabled,
@@ -1971,6 +1971,13 @@ const x = 1;
expect(status.currentBranch).toBe("feature/plain");
});
it("rejects merging a branch into its own same-named remote base", async () => {
execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir });
await expect(mergeToBase(repoDir, { baseRef: "refs/remotes/origin/main" })).rejects.toThrow(
"Cannot merge a branch into itself",
);
});
it("merges the current branch into base from a worktree checkout", async () => {
const worktree = await createLegacyWorktreeForTest({
branchName: "main",
+1 -1
View File
@@ -3942,7 +3942,7 @@ export async function mergeToBase(
const normalizedBaseRef = resolveMergeTargetBranch(baseRef);
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
if (normalizedBaseRef === currentBranch) {
return currentWorktreeRoot;
throw new Error("Cannot merge a branch into itself");
}
const baseWorktree = await getWorktreePathForBranch(cwd, normalizedBaseRef);