diff --git a/lib/merge_orchestration/baton_emitter.ex b/lib/merge_orchestration/baton_emitter.ex index 5578a1de..0e76db0a 100644 --- a/lib/merge_orchestration/baton_emitter.ex +++ b/lib/merge_orchestration/baton_emitter.ex @@ -85,7 +85,8 @@ defmodule Hypatia.MergeOrchestration.BatonEmitter do defp default_submit(spec), do: apply(Bag.Mesh, :submit_planned, [spec]) defp gh_flag(:squash), do: "--squash" - defp gh_flag(:rebase), do: "--rebase" + # :rebase is never emitted -- a stale decision carrying it squashes (see + # Strategist.decide_method/1); rebase is disabled estate-wide. defp gh_flag(:merge_commit), do: "--merge" defp gh_flag(_), do: "--squash" diff --git a/lib/merge_orchestration/strategist.ex b/lib/merge_orchestration/strategist.ex index 00c85d53..7898b86c 100644 --- a/lib/merge_orchestration/strategist.ex +++ b/lib/merge_orchestration/strategist.ex @@ -77,11 +77,15 @@ defmodule Hypatia.MergeOrchestration.Strategist do defp min_safety(a, b), do: if(@order[a] <= @order[b], do: a, else: b) # --- Method axis: repo policy default ⊕ commit hygiene (never confidence) --- + # Never :rebase: rebase-merge replays commits UNSIGNED (119 of 286 unsigned + # estate default-branch commits were such replays) and breaks + # required_signatures; the estate disables it repo-side (owner ruling + # 2026-09-30). An atomic-green series is squashed like any other change; + # only a proof keeps its series, as a (GitHub-signed) merge commit. defp decide_method(ctx) do cond do ctx.change_class in [:chore, :bump] -> :squash ctx.change_class == :proof -> :merge_commit - Map.get(ctx, :commits_atomic_green, false) -> :rebase true -> :squash end end diff --git a/test/merge_orchestration/baton_emitter_test.exs b/test/merge_orchestration/baton_emitter_test.exs index 17633be7..a32d06ac 100644 --- a/test/merge_orchestration/baton_emitter_test.exs +++ b/test/merge_orchestration/baton_emitter_test.exs @@ -37,7 +37,8 @@ defmodule Hypatia.MergeOrchestration.BatonEmitterTest do end test "method maps to the gh flag; aggressive pools raise the planner risk" do - assert BatonEmitter.to_spec(dec(%{method: :rebase})).command |> List.last() == "--rebase" + # rebase replays commits unsigned: a stale :rebase decision must squash + assert BatonEmitter.to_spec(dec(%{method: :rebase})).command |> List.last() == "--squash" assert BatonEmitter.to_spec(dec(%{method: :merge_commit})).command |> List.last() == "--merge" assert BatonEmitter.to_spec(dec(%{pool: :p3})).risk == :high assert BatonEmitter.to_spec(dec(%{pool: :mass_squash})).risk == :high diff --git a/test/merge_orchestration/strategist_test.exs b/test/merge_orchestration/strategist_test.exs index 8683be1b..8fa378f3 100644 --- a/test/merge_orchestration/strategist_test.exs +++ b/test/merge_orchestration/strategist_test.exs @@ -56,6 +56,13 @@ defmodule Hypatia.MergeOrchestration.StrategistTest do assert Strategist.decide(ctx(%{pool: :p0})).safety == :flag end + test "an atomic-green commit series squashes: rebase is never chosen" do + # a non-chore class, so the method cond reaches its commit-hygiene clauses + # (the default :chore squashes on the first clause and would prove nothing) + d = Strategist.decide(ctx(%{change_class: :security, commits_atomic_green: true})) + assert d.method == :squash + end + test "method is set by class, not confidence: a low-confidence chore still squashes" do d = Strategist.decide(ctx(%{attestations: [%{bot: "ci", verdict: :approve, confidence: 0.10}]}))