Fix fast-forward merge when a tag shares the target branch name

What does this MR do and why?

Fast-forward merges fail when a tag has the same name as the target branch and points at a different commit. The fast-forward now reads the target branch tip from the fully qualified refs/heads/<branch> ref, so a same-named tag is ignored.

Detailed context for AI agents

Change

  • File: app/services/merge_requests/merge_strategies/from_source_branch.rb, method fast_forward!.
  • prior_target_sha = repository.commit(merge_request.target_branch)&.sha becomes repository.commit(merge_request.target_branch_ref)&.sha.
  • target_branch_ref is refs/heads/<branch>. The same file already passes merge_request.target_branch_ref to CreateRefService, and MergeRequest#target_branch_head uses it too.

Root cause and data flow

  • prior_target_sha is sent to Gitaly UserFFBranch as expected_old_oid. It is an optimistic lock on the branch.
  • A bare name is resolved as refs/tags/<name> before refs/heads/<name>.
  • If a tag has the same name as the target branch and points at a different commit, the lock value is the tag's commit.
  • Gitaly refuses the update with FailedPrecondition ("reference does not point to expected object"). Expected is the tag commit, actual is the branch tip.
  • The client maps that refusal to nil. The merge then fails with Fast-forward merge did not advance the target branch.
  • On 19.2 the user only sees "An error occurred while merging". Retrying never helps. The MR and source branch are left intact.

Regression history

  • In 19.1 this read, and the target_sha argument, were behind the default-off flag verify_ff_merge_advancement.
  • The flag was removed in 19.2 (commit 13d415ea). Self-managed instances got the behaviour in 19.2.0 with no way to turn it off.
  • Reported by a self-managed customer on 19.2.6. A reproduction also exists on GitLab.com (by Support).
  • The check itself was added for the no-op / source-branch-deletion bug in https://gitlab.com/gitlab-org/gitlab/-/work_items/598820. Flag rollout: #602293 (closed).

Workaround until this ships

  • Switch the project to the "Merge commit" merge method. That path sends no expected old value.
  • Or rename the tag.

Why no feature flag

  • It switches one read to the qualified ref the rest of the file already uses. Behaviour only changes when another ref shadows the branch name, which is exactly the broken case.

Verification

  • New example in spec/services/merge_requests/merge_strategies/from_source_branch_spec.rb, against a real Gitaly repository. A tag with the target branch's name points behind the branch tip. The spec asserts the fast-forward lands on the branch.
  • Without the fix it fails with exactly Fast-forward merge did not advance the target branch. With the fix it passes.
  • from_source_branch_spec.rb (28 examples) and spec/services/merge_requests/merge_service_spec.rb (59 examples) pass.
  • Reproduced end to end on GDK through Praefect. On master the merge fails and Gitaly logs the expected/actual pattern above. The same setup merges with the 19.1 flag-off code and with this change.

Out of scope (same bare-name read elsewhere, left for follow-ups)

  • Projects::Forks::SyncService#head_sha. It is also used as a lock.
  • Repository#commit_files. It passes target_sha to UserCommitFiles. Pre-existing since 2024, Source Code group.
  • MergeRequest#squash_commit_reachable_from_target_branch? and MergeTrains::RefreshMergeRequestService#merge_commit_landed_on_target?. Both are read-only checks.
  • The :reference_update to nil mapping in Gitlab::GitalyClient::OperationService#user_ff_branch (Source Code group). It hides why the update was refused, but it is not the cause here.

References

Screenshots or screen recordings

Backend only, no UI change.

How to set up and validate locally

  1. In a project, set Settings > Merge requests > Merge method to "Fast-forward merge".
  2. Create branch ff-target from the default branch.
  3. Create branch ff-source from the default branch, add one commit to it, and open a merge request from ff-source into ff-target.
  4. Create a tag named ff-target from the parent of the default branch's tip commit (any commit behind ff-target).
  5. Merge the merge request. On master it fails with "Fast-forward merge did not advance the target branch". On this branch it merges and ff-target moves to the ff-source commit.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Merge request reports

Loading
Loading