Show a clear error when automatic rebase before merge conflicts

When automatic rebase before merge fails because of a conflict, the widget currently shows only the generic "An error occurred while merging" - the customer in the issue retried the merge repeatedly with no way to tell they had to resolve a conflict. This change shows an explicit message for that case: "Automatic rebase before merge failed because the source branch conflicts with the target branch. Rebase the source branch manually and resolve the conflicts." All other failures keep today's generic message - raw Gitaly text is never shown to users, only logged.

Detailed context for AI agents

Issue: #616915 (closed)

A customer retried a merge repeatedly with no indication the real cause was a rebase conflict, and was only unblocked by disabling automatic rebase (see #524048 (comment 3687401486)).

Root cause: MergeRequests::MergeService#try_merge has a blanket rescue StandardError that logs the real message server-side and re-raises the generic GENERIC_ERROR_MESSAGE. This swallowed MergeRequests::MergeStrategies::StrategyError. The outer rescue in #execute already handles StrategyError with save_message_on_model: true (i.e. strategy messages were intended to be user-facing), but it only ever saw StrategyErrors raised from validate!, which runs outside try_merge. Git-merge-time failures (the rebase performed via MergeRequests::CreateRefService) never reached that outer rescue.

Fix (3 small changes):

  1. try_merge re-raises MergeRequests::MergeStrategies::StrategyError before the rescue StandardError clause, so the outer rescue persists the message on merge_request.merge_error (surfaced in the MR widget). This is safe because every StrategyError message is app-curated text, never raw Gitaly output (see change 3).
  2. MergeRequests::CreateRefService classifies conflicts by returning reason: REBASE_CONFLICT on the error ServiceResponse. Gitaly exposes no structured error here: UserRebaseToRef catches its internal typed RebaseConflictError but flattens it to a plain FailedPrecondition reading failed to rebase X on Y while preparing Z due to conflict (no WithDetail, unlike UserRebase/UserMergeBranch/UserSquash). Classification matches that wording only. The merge step (UserMergeToRef) is deliberately not classified: in this flow it merges the already-rebased head onto its base so it cannot conflict, and it reports one generic Failed to create merge commit ... message for all failures anyway. If Gitaly ever rephrases, production fails open to the generic message - never to showing raw text. ServiceResponse messages are unchanged, so other consumers (merge trains) are unaffected.
  3. MergeStrategies::FromSourceBranch#execute_git_merge! maps the reason: REBASE_CONFLICT raises a StrategyError with the curated conflict message; any other create-ref failure raises a plain RuntimeError with the raw message, which try_merge logs and converts to the generic message - byte-for-byte today's behaviour for non-conflict failures.

Side benefit: the "Fast-forward merge did not advance the target branch" message (rebase-collapse guard, https://gitlab.com/gitlab-org/gitlab/-/work_items/598820) and squash failure messages - already app-curated - were also being swallowed to the generic message and now surface via change 1.

How this was found: while production-testing the retain_auto_merge_with_automatic_rebase rollout (#611435), on a test MR whose source branch was 3-way-merge-clean but rebase-conflicting (one commit changes a line, a second commit reverts it, target branch changes the same line). mergeable? passes because it does a 3-way merge test, the merge fires, the commit-by-commit rebase conflicts, and the MR ends up with detailed_merge_status=mergeable and only the generic merge_error. With auto-merge armed this failure is effectively invisible - the MR never becomes "unmergeable", so no to-do or email fires. The retain flag makes this path more frequent since auto-merges now survive target-branch pushes.

Verification: new spec in spec/services/merge_requests/merge_service_spec.rb asserting the StrategyError message lands on merge_error; specs in spec/services/merge_requests/merge_strategies/from_source_branch_spec.rb covering both the conflict (curated message) and non-conflict (plain re-raise) paths; specs in spec/services/merge_requests/create_ref_service_spec.rb drive the real Gitaly RPCs with genuinely conflicting branches (no stubbing) - the ff case asserts the conflict is classified and the merge-commit case asserts it is not - so the message match fails loudly in CI if Gitaly's wording ever drifts. All three files pass locally. RuboCop clean.

Deliberately out of scope:

  • Surfacing any detail for non-conflict rebase failures - kept generic on purpose so raw Gitaly text never reaches users.
  • A structured conflict error from Gitaly's UserRebaseToRef RPC (adding WithDetail(MergeConflictError) like its sibling RPCs already do), which would replace the message match (possible follow-up).
  • Aborting/notifying the auto-merge when a merge attempt fails (possible follow-up for #611435).
  • i18n of the new message: neighbouring messages in these classes are plain strings, and merge_error is persisted per-MR, so render-time translation would not apply anyway.

Resolves #616915 (closed)

Edited by Marc Shaw

Merge request reports

Loading
Loading