Skip to content

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

@i-trytoohard

Description

@i-trytoohard

Problem

The merge method is hardcoded to squash everywhere — both the dashboard merge button and the auto-merge reaction. Users who want to preserve full git history have no way to use fast-forward or merge commits.

Requested by @Tin in Discord — they want:

  1. Try fast-forward first (preserves clean linear history)
  2. If ff fails (branch diverged), fall back to a merge commit
  3. No squash, no rebase — git history must not be lost

Solution

Add a mergeMethod config option per project, defaulting to "squash" (backward compatible).

New "ff-only" strategy: tries gh pr merge --ff-only, and if that fails (non-fast-forward), automatically retries with --merge (merge commit).

Config Usage

projects:
  my-app:
    repo: owner/my-app
    path: ~/my-app
    mergeMethod: ff-only    # try fast-forward, fall back to merge commit

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

Files Changed (4 files, +21 -7)

  1. packages/core/src/types.ts — Add "ff-only" to MergeMethod type, add mergeMethod?: MergeMethod to ProjectConfig
  2. packages/core/src/config.ts — Add mergeMethod to ProjectConfigSchema with default "squash"
  3. packages/plugins/scm-github/src/index.tsmergePR() handles "ff-only": try --ff-only, fall back to --merge
  4. packages/web/src/app/api/prs/[id]/merge/route.ts — Reads project.mergeMethod instead of hardcoding "squash"

Verification

  • pnpm typecheck passes for core, scm-github
  • All 1340 core tests pass
  • All merge-related web tests pass

Patch

Branch feat/merge-method-config is at commit e6eadd09 on the local checkout. Bot can't push (classic PAT 403). Patch file attached — someone with push access can apply it.

diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index 145a7b2c..6105a2b3 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -267,6 +267,8 @@ const ProjectConfigSchema = z.object({
   orchestrator: RoleAgentConfigSchema,
   worker: RoleAgentConfigSchema,
   reactions: z.record(ReactionConfigSchema.partial()).optional(),
+  /** Merge strategy for auto-merge and dashboard merge. Default: "squash". */
+  mergeMethod: z.enum(["merge", "squash", "rebase", "ff-only"]).default("squash"),
   agentRules: z.string().optional(),
   agentRulesFile: z.string().optional(),

diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 86850228..81edd375 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -981,7 +981,7 @@ export const PR_STATE = {
   CLOSED: "closed" as const,
 } satisfies Record<string, PRState>;
 
-export type MergeMethod = "merge" | "squash" | "rebase";
+export type MergeMethod = "merge" | "squash" | "rebase" | "ff-only";
 
@@ -1579,6 +1579,9 @@ export interface ProjectConfig {
   reactions?: Record<string, Partial<ReactionConfig>>;
+  /** Merge strategy for auto-merge and dashboard merge. Default: "squash". */
+  mergeMethod?: MergeMethod;

diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts
index fa59cecd..78d707e1 100644
--- a/packages/plugins/scm-github/src/index.ts
+++ b/packages/plugins/scm-github/src/index.ts
@@ -839,9 +839,17 @@ function createGitHubSCM(): SCM {
     async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise<void> {
-      const flag = method === "rebase" ? "--rebase" : method === "merge" ? "--merge" : "--squash";
-      await gh(["pr", "merge", String(pr.number), "--repo", repoFlag(pr), flag, "--delete-branch"]);
+      if (method === "ff-only") {
+        try {
+          await gh(["pr", "merge", String(pr.number), "--repo", repoFlag(pr), "--ff-only", "--delete-branch"]);
+        } catch {
+          await gh(["pr", "merge", String(pr.number), "--repo", repoFlag(pr), "--merge", "--delete-branch"]);
+        }
+      } else {
+        const flag = method === "rebase" ? "--rebase" : method === "merge" ? "--merge" : "--squash";
+        await gh(["pr", "merge", String(pr.number), "--repo", repoFlag(pr), flag, "--delete-branch"]);
+      }

diff --git a/packages/web/src/app/api/prs/[id]/merge/route.ts b/packages/web/src/app/api/prs/[id]/merge/route.ts
index d4ea77d0..4faed338 100644
--- a/packages/web/src/app/api/prs/[id]/merge/route.ts
+++ b/packages/web/src/app/api/prs/[id]/merge/route.ts
@@ -89,7 +89,8 @@
-    await scm.mergePR(targetPR, "squash");
+    const mergeMethod = project.mergeMethod ?? "squash";
+    await scm.mergePR(targetPR, mergeMethod);
@@ -108,7 +109,7 @@
-      data: { prNumber, method: "squash" },
+      data: { prNumber, method: mergeMethod },
@@ -113,7 +114,7 @@
-      { ok: true, prNumber, method: "squash" },
+      { ok: true, prNumber, method: mergeMethod },

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions