Skip to content

feat: configurable merge method with server-side fast-forward#2101

Open
NNTin wants to merge 2 commits into
AgentWrapper:mainfrom
NNTin:feat/merge-strategy
Open

feat: configurable merge method with server-side fast-forward#2101
NNTin wants to merge 2 commits into
AgentWrapper:mainfrom
NNTin:feat/merge-strategy

Conversation

@NNTin
Copy link
Copy Markdown

@NNTin NNTin commented Jun 5, 2026

Summary

Adds a per-project mergeMethod config option so users can choose how the dashboard merge action merges PRs. Default remains "squash" (backward compatible). Consumed by the dashboard merge route via scm.mergePR.

Note: The auto-merge reaction is currently a notify-only stub and does not call scm.mergePR, so it does not consume mergeMethod. This option takes effect from the dashboard merge action.

Strategies

Method Behavior
squash (default) GitHub "Squash and merge"
merge GitHub "Create a merge commit"
rebase GitHub "Rebase and merge"
merge-with-ff AO composite strategy (see below)

merge-with-ff

GitHub has no fast-forward merge buttongh pr merge only offers merge/squash/rebase, and --merge always creates a merge commit even when the branch could fast-forward. So merge-with-ff can't go through gh pr merge; it uses the Git Refs API directly:

  1. Compare the PR head against base (GET /repos/{o}/{r}/compare/{base}...{head}).
  2. If the branch is strictly ahead (no divergence): fast-forward the base ref server-side — PATCH /repos/{o}/{r}/git/refs/heads/{base} with force=false (GitHub rejects anything that isn't a true fast-forward), then delete the head branch.
  3. If the branches have diverged: fall back to a --merge commit.

This preserves linear history (no merge commit, no squash, no rebased SHAs) whenever possible.

Config Usage

projects:
  my-app:
    repo: owner/my-app
    path: ~/my-app
    mergeMethod: merge-with-ff   # fast-forward when possible, else merge commit

Options: "squash" (default), "merge", "rebase", "merge-with-ff"

Files Changed

  1. packages/core/src/types.ts — Add "merge-with-ff" to MergeMethod (with JSDoc), add mergeMethod? to ProjectConfig
  2. packages/core/src/config.ts — Add mergeMethod to ProjectConfigSchema (default "squash")
  3. packages/plugins/scm-github/src/index.tsmergePR() implements merge-with-ff via compare + Git Refs fast-forward, else merge commit
  4. packages/plugins/scm-gitlab/src/index.tsmergePR() throws for the unsupported "merge-with-ff" (no GitLab equivalent) instead of silently ignoring it
  5. packages/web/src/app/api/prs/[id]/merge/route.ts — Reads project.mergeMethod instead of hardcoding "squash"

Tests

  • scm-github: merge-with-ff fast-forwards via Git Refs API when ahead (and never touches gh pr merge); falls back to --merge when diverged
  • scm-gitlab: merge-with-ff throws and never shells out to glab
  • web merge route: defaults to squash; reads and passes through project.mergeMethod

Caveats

  • Branch protection that requires PRs/reviews/status checks before changes to the base branch will reject the server-side ref update (same wall a direct push hits).
  • Fast-forwarding the base ref makes the head commits reachable from base; GitHub then marks the PR as merged.

Closes #2095

🤖 Generated with Claude Code

Add a per-project `mergeMethod` config option (default "squash", backward
compatible) consumed by the dashboard merge action via `scm.mergePR`.

Supported strategies:
- "squash" / "merge" / "rebase" — native GitHub merge-button methods
- "merge-with-ff" — AO composite strategy: when the PR branch is strictly
  ahead of base (no divergence) it fast-forwards the base ref server-side via
  the Git Refs API (PATCH .../git/refs/heads/<base> with force=false) and
  deletes the head branch; when the branches have diverged it falls back to a
  merge commit. GitHub exposes no fast-forward merge button, so this cannot go
  through `gh pr merge` (which only offers merge/squash/rebase).

The GitLab SCM throws an explicit error for "merge-with-ff" rather than
silently ignoring it, since it has no equivalent per-merge flag.

Closes AgentWrapper#2095

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@i-trytoohard
Copy link
Copy Markdown
Contributor

i-trytoohard commented Jun 5, 2026

action: auto-merge is currently a stub

Looking at the current implementation in packages/core/src/lifecycle-manager.ts, the auto-merge reaction case does not actually merge PRs:

case "auto-merge": {
  // Auto-merge is handled by the SCM plugin
  // For now, just notify
  await notifyHuman(event, "action");
  recordActivityEvent({ summary: `auto-merge ${reactionKey}` });
  return { reactionType: reactionKey, success: true, action: "auto-merge", escalated: false };
}

The code comment says it explicitly: "For now, just notify."

What it currently does:

  • Sends a human notification via notifyHuman() (Discord/Telegram/etc.)
  • Records an activity event
  • Returns { success: true } without calling any merge API

Where the real merge lives:

The actual mergePR() implementation exists in the GitHub SCM plugin (packages/plugins/scm-github/src/index.ts), but it is only wired up to the dashboard merge button API route (packages/web/src/app/api/prs/[id]/merge/route.ts), not to the lifecycle reaction system.

This is out of scope for this PR, but once the SCM plugin is properly integrated into the lifecycle reaction pipeline, the auto-merge action will benefit from it and can be wired up to call scm.mergePR() directly.

…with-ff

Address PR review on merge-with-ff:
- Type getCompareStatus's return as a CompareStatus union instead of string.
- Fast-forward on "identical" as well as "ahead" so a zero-diff branch
  no-ops via the Refs API instead of attempting a pointless merge commit.
- Wrap the base-ref PATCH so a rejected fast-forward (protected branch or a
  base that advanced since the comparison) surfaces a clear, actionable
  error instead of a raw API failure, and document the protected-branch
  limitation on fastForwardBase.

Add tests for the identical-branch FF path and the rejected-update error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NNTin
Copy link
Copy Markdown
Author

NNTin commented Jun 5, 2026

Addressing code review feedback

A code review of this PR's merge-with-ff strategy came in via Discord (thread). Summarizing the findings and what I changed in response (commit 34db21a).

What I implemented

1. getCompareStatus returned string instead of a union type, and the identical case fell through to a pointless merge commit.
GitHub's compare endpoint returns one of ahead / behind / diverged / identical. The old code only special-cased ahead, so an identical branch (head and base point at the same commit, zero diff) fell into the --merge fallback and would attempt a needless — and likely failing — merge commit.

  • Typed the return as CompareStatus = "ahead" | "behind" | "diverged" | "identical".
  • Fast-forward now triggers on ahead || identical, so a zero-diff branch no-ops cleanly via the Refs API.
  • Added a test covering the identical FF path.

2. Protected base branches break the fast-forward path with an opaque error.
This is the deeper one. merge-with-ff fast-forwards by writing the base ref directly through the Git Refs API (PATCH .../git/refs/heads/{base} with force=false). A protected base branch (common on main) rejects direct ref writes by design — so the FF path is fundamentally unavailable there, and the failure previously surfaced as a raw API error.

  • Wrapped the PATCH so a rejected fast-forward — whether from branch protection or a base that advanced mid-merge — throws a clear, actionable error pointing the user at merge / squash / rebase.
  • Documented the protected-branch limitation on fastForwardBase.
  • Added a test asserting the clear error on a rejected update.

What I deliberately did not change, and why

Retry logic for the compare→PATCH race. The review suggested retrying if the base moves between the compare and the PATCH. I left this out: force=false already guarantees correctness — GitHub rejects the update if the base SHA moved, so there is no clobber/data-loss risk. A retry loop adds complexity and risks a double-merge for no correctness gain. The new error message already explains "base advanced since the comparison," so a manual retry is straightforward.

Best-effort head-branch deletion. The review noted the post-FF DELETE of the head ref could fail. I kept it strict to match gh pr merge --delete-branch parity; making only this path best-effort would diverge from the native-merge behavior without a clear need.

Verification

  • scm-github tests: 100 passing (was 98), typecheck and lint clean.

Thanks for the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: configurable merge method (ff-only + merge commit fallback)

2 participants