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
- On a project, set the merge method to "Merge commit with semi-linear history" or "Fast-forward merge".
- Turn on "Enable automatic rebase prior to merge" (
automatic_rebase_enabled) for the project. - 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.
- Merge the merge request.
- Call
GET /projects/:id/repository/commits/:sha/merge_requestsfor 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_shahappens to equal it. - Plain merge commit, no rebase: all commits resolve correctly, for contrast.
Three user-visible surfaces are affected:
- The commits API,
GET /projects/:id/repository/commits/:sha/merge_requests, returns nothing for a rebased SHA. - The commit page's related merge requests line (
app/controllers/projects/commit_controller.rb, rendered byapp/assets/javascripts/commit_merge_requests.js) shows "No related merge requests found". - Changelog generation (
Repositories::ChangelogServiceviaMergeRequests::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.
store_generated_ref_commitscallsupsert_all(records, unique_by: [:id, :project_id]), butidis 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; twoCreateRefServiceruns produced 6 rows for a 2-commit merge request in testing. Merge trains avoid the consequence becauseMergeTrains::Car#cleanup_refdeletes the rows, but the automatic-rebase path has no equivalent cleanup. Mostly mitigated by follow-up 2.- Rows are written inside
CreateRefService, before the target branch actually moves. Stubbingff_mergeto fail confirms it: the merge request stays open, the rows persist, andMergeRequest.by_related_commit_sharesolves a merge request from a commit that never landed.OldestPerCommitFinderis unaffected, because it filters onstate_id = merged. Already tracked as #571785 ("Only persist generated ref commits after merge"). - Separate pre-existing bug.
app/services/merge_requests/merge_strategies/from_source_branch.rbpassesonly: [:rebase_on_merge_path], an array, toMergeRequest#schedule_cleanup_refs, butMergeRequest#refs_to_cleanuptests 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 byMergeRequests::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. 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:MergeRequestsFinderapplies project and permission scoping before the union, and the changelog path filtersproject_idexplicitly. 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 byproject_idon a table that is range-partitioned byproject_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.