Merge request widget hides the reason a rebase was rejected

Summary

The rebase endpoint sends the reason it refused a rebase. The merge request widget throws that reason away and shows "Failed to rebase. Please try again." instead, which is wrong advice, because retrying cannot succeed. On a project that requires a fast-forward or semi-linear merge, the same rejection shows nothing at all.

POST /:namespace/:project/-/merge_requests/:iid/rebase answers 403 with {"merge_error": "<reason>"} when the rebase is refused before it is queued, and 409 with the same key on a lock timeout. The reason has been on the wire since 14.6 (!75496 (merged)), and 15.8 made the UI and the API agree that a user who cannot force push gets a 403 carrying it (#386537 (closed)).

🛠️ with ❤️ at Siemens

Steps to reproduce

  1. Fork a project.
  2. In the fork, protect the branch you will use as the source branch and leave Allowed to force push off.
  3. Add a commit to the target branch in the upstream project, so the merge request ends up behind its target.
  4. Open a merge request from the protected branch in the fork to the upstream project.
  5. As a user who is allowed to push to that branch, open the merge request, expand Merge details and select Rebase source branch.

For the second variant, set the upstream project's merge method to Fast-forward merge, then use the Rebase button that the merge checks list offers.

Example Project

No public example project. The setup is two projects and one merge request:

Setting
Upstream project default branch protected, Allowed to force push off
Fork same branch protected, Allowed to force push off
Merge request fork's protected branch into the upstream project's branch, one commit behind and one commit ahead of the target

The merge request has to be behind its target, otherwise the Rebase button does not render.

What is the current bug behavior?

The request answers:

403 {"merge_error":"Source branch is protected from force push"}

The widget then shows:

  • In the merge widget, an alert reading "Failed to rebase. Please try again."
  • In the merge checks list, no alert at all.

The same happens for the endpoint's other reasons, Source branch does not exist and Cannot push to source branch, and for the 409 lock-timeout message.

What is the expected correct behavior?

The alert names the reason the backend sent, for example:

Failed to rebase: Source branch is protected from force push.

Both surfaces behave the same way, and the generic sentence appears only when no reason was sent.

Relevant logs and/or screenshots

Expand for the endpoint responses and the code that produces them

The three reasons raised before the rebase is queued, from rebase_service.rb:

return error_response(_('Source branch does not exist')) unless
  merge_request.source_branch_exists?

return error_response(_('Cannot push to source branch')) unless
    user_access.can_push_to_branch?(merge_request.source_branch)

return error_response(_('Source branch is protected from force push')) unless
    merge_request.permits_force_push?

Rendered by merge_requests_controller.rb:

render json: { merge_error: result.message }, status: :forbidden

Worth noting: the button and the request disagree by design. The button asks MergeRequestPresenter#can_push_to_source_branch?, which a protected branch can satisfy. The request asks MergeRequest#permits_force_push?, which the same protected branch fails when force push is off. So the button is offered and then refused, and the refusal is the only place the user could learn why.

Output of checks

Reproduced on a current master GDK, 19.4.0-pre EE. Not verified on GitLab.com.

Results of GitLab environment info

Expand for output related to GitLab environment info
GitLab 19.4.0-pre EE, GDK, master at 031c10901d819ce7d112b0e5a32384da590ecdf2
Affected files are Community Edition, under app/assets/javascripts/

Possible fixes

Two independent places drop the reason:

Component Line What happens
states/ready_to_merge.vue 586 Reads error.response.data.message. The payload key is merge_error, so the read never matches and the generic sentence always wins. Introduced in 17.9 by !179906 (merged), which added the Rebase button to the widget
checks/rebase.vue 128 Raises its alert only when merge_error is absent, so a reason produces silence. Present since the component was created in October 2023

checks/rebase.vue appears to assume some other surface renders the reason. None does. The widget does have a merge-error banner, mr_widget_options.vue, but it is fed by the persisted merge_error column and by an event that also moves the widget into its failedToMerge state. A rebase refused before it is queued never writes that column, and the merge request is still open and mergeable, so that banner is the wrong surface here.

Suggested direction: both call sites read merge_error from the response and fall back to the generic sentence only when it is absent. A shared helper keeps the two from drifting apart again. The reason may or may not end in a period, i.e. the three refusal reasons do not and the lock-timeout message does, so the formatting has to handle both, the way mr_widget_options.vue already does for merge errors.

Neither component has a test on the rejection path today, which is why this went unnoticed for several releases. Any fix should add one per call site.

Out of scope: the wording of the reasons themselves. Cannot push to source branch still does not name the protected-branch rule that denied it. That belongs in a separate change.

#616915 (closed) covers the same class of problem for the automatic rebase inside a merge. Different endpoint and different message, so it is not a duplicate.

Edited by Gerardo Navarro