Automatic rebase before merge does not record the rebased commit to merge request association

Summary

Automatic rebase before merge (project setting automatic_rebase_enabled, GA in 19.2, available in all tiers) rewrites a merge request's commits onto a temporary ref, fast-forwards the target branch to them, and discards the ref, but nothing records that the rewritten commit SHAs belong to the merge request. Commits merged this way land on the target branch with no commit-to-merge-request association, which breaks the commits API, the commit page, and changelog generation. The original report framed this as specific to semi-linear history, with fast-forward believed to retain the association, but that is not accurate: fast-forward is broken too for every commit except the tip, and only looks correct on single-commit merge requests. The real boundary is merge trains versus everything else, not fast-forward versus semi-linear.

Reported by a large self-managed Premium customer via a Request For Help, https://gitlab.com/gitlab-com/request-for-help/-/work_items/5340 (Zendesk ticket 745646), whose production integration syncs commits to another system using this association to carry merge request metadata.

Steps to reproduce

  1. On a project, set the merge method to "Merge commit with semi-linear history" or "Fast-forward merge".
  2. Turn on "Enable automatic rebase prior to merge" (automatic_rebase_enabled) for the project.
  3. Create a merge request with at least two commits on the source branch, with the source branch behind the target branch, so a rebase is required at merge time.
  4. Merge the merge request.
  5. Call GET /projects/:id/repository/commits/:sha/merge_requests for one of the rebased commit SHAs, or open the commit page for that commit.

Current behaviour

For the two-commit case above:

  • Semi-linear history + automatic rebase: both rebased commits are orphaned; only the merge commit resolves back to the merge request.
  • Fast-forward + automatic rebase: the earlier rebased commit is orphaned; only the tip resolves, and only because merged_commit_sha happens to equal it.
  • Plain merge commit, no rebase: all commits resolve correctly, for contrast.

Three user-visible surfaces are affected:

  1. The commits API, GET /projects/:id/repository/commits/:sha/merge_requests, returns nothing for a rebased SHA.
  2. The commit page's related merge requests line (app/controllers/projects/commit_controller.rb, rendered by app/assets/javascripts/commit_merge_requests.js) shows "No related merge requests found".
  3. Changelog generation (Repositories::ChangelogService via MergeRequests::OldestPerCommitFinder) attributes the commit to the wrong merge request, or none.

The changelog case is concrete. Merge request A merges feature into main with automatic rebase, then merge request B promotes main into a stable release branch, so B's diff carries the same rebased SHAs. The changelog credits B for a commit that A actually introduced, because A's rows are missing, so B wins by default.

Expected behaviour

Every commit that lands on the target branch through automatic rebase should resolve back to its merge request, the same way commits already do for a plain merge commit or a manually rebased-then-merged one. In the changelog case above the commit should be credited to merge request A, and the commit page and commits API should list both A and B instead of just B.

Root cause

The only writer of the p_generated_ref_commits table is MergeRequests::CreateRefService#store_generated_ref_commits, gated behind should_store_generated_ref_commits?. The CE implementation (app/services/merge_requests/create_ref_service.rb) returns false unconditionally, with the comment "only available in ee for merge trains for now". The EE override (ee/app/services/ee/merge_requests/create_ref_service.rb) returns super || (target_project.can_create_new_ref_commits? && merge_request.merge_train_car.present?), so rows are only written when a merge train car exists. The automatic-rebase path (MergeRequests::MergeStrategies::FromSourceBranch#use_create_ref_service?) calls the same CreateRefService, but has no train car, so nothing is recorded.

Nothing else covers the rewritten SHAs. The merge request diff still holds the pre-rebase SHAs, unlike the manual Rebase button, which rewrites the source branch and refreshes the diff. merged_commit_sha and merge_commit_sha only cover the final tip commit.

Verified broken in both CE (FOSS_ONLY=1) and EE, with and without licensed features stubbed. The EE override never consults the license, only the presence of a merge train car.

This is the same class of bug as #436943 (closed) ("Missing MR links on rebased merge train commits (FF merge)"), fixed in 18.3 by !195831 (merged). That fix introduced the generated_ref_commits mechanism but scoped it to merge trains.

Scope of the fix

Change should_store_generated_ref_commits? in CE from false to target_project.can_create_new_ref_commits?, behind a new feature flag generated_ref_commits_for_automatic_rebase (type gitlab_com_derisk, default disabled, project actor). CreateRefService is only reachable from the merge-train path and the automatic-rebase path, and both already require merge_method != :merge, which is exactly what can_create_new_ref_commits? checks. The change is CE-only but takes effect in both editions, because the EE override is super || (...).

The original merge-train fix was derisked the same way, behind a flag named generate_ref_commits (#558276 (closed)). There is no Service Ping metric for automatic_rebase_enabled, so the affected population cannot be sized before shipping, which is the main argument for gating this.

This is not retroactive. Already-merged merge requests stay orphaned, and backfilling would mean walking merge history per project.

Follow-ups

Each of these is separate work, deliberately not part of the fix above.

  1. store_generated_ref_commits calls upsert_all(records, unique_by: [:id, :project_id]), but id is absent from the records, so the conflict target is the primary key and never conflicts, providing no deduplication. Rebase is not SHA-stable, so each merge attempt writes a fresh set of rows; two CreateRefService runs produced 6 rows for a 2-commit merge request in testing. Merge trains avoid the consequence because MergeTrains::Car#cleanup_ref deletes the rows, but the automatic-rebase path has no equivalent cleanup. Mostly mitigated by follow-up 2.
  2. Rows are written inside CreateRefService, before the target branch actually moves. Stubbing ff_merge to fail confirms it: the merge request stays open, the rows persist, and MergeRequest.by_related_commit_sha resolves a merge request from a commit that never landed. OldestPerCommitFinder is unaffected, because it filters on state_id = merged. Already tracked as #571785 ("Only persist generated ref commits after merge").
  3. Separate pre-existing bug. app/services/merge_requests/merge_strategies/from_source_branch.rb passes only: [:rebase_on_merge_path], an array, to MergeRequest#schedule_cleanup_refs, but MergeRequest#refs_to_cleanup tests membership with %i[all rebase_on_merge_path].include?(only), which is false for an array. The intended immediate cleanup deletes nothing, so the temporary ref survives every automatic-rebase merge and is only removed 14 days later by MergeRequests::CleanupRefsService. A merge request whose merge fails and stays open has no cleanup schedule at all. The fix is one word: pass the symbol, not an array.
  4. MergeRequest.by_generated_ref_commit_sha(sha) takes no project argument, so an unscoped call returns merge requests from other projects that share the SHA, which forks do. No leak exists today, because both real callers scope by project first: MergeRequestsFinder applies project and permission scoping before the union, and the changelog path filters project_id explicitly. Adding project scoping defensively would also let the query use the existing indexes, (project_id, commit_sha) and (project_id, merge_request_iid), both led by project_id on a table that is range-partitioned by project_id.

Verification

With the fix, all three replication cases resolve every landed commit to its merge request. Existing specs pass unchanged, and the new integration test fails without the fix and passes with it. Suites run: spec/services/merge_requests/create_ref_service_spec.rb, ee/spec/services/ee/merge_requests/create_ref_service_spec.rb, spec/services/merge_requests/merge_strategies/from_source_branch_spec.rb, spec/services/merge_requests/merge_service_spec.rb, spec/finders/merge_requests/oldest_per_commit_finder_spec.rb - 133 examples, 0 failures.