Draft: feat(approvals): smarter approval resets for merge request rebases

Problem

When a merge request is rebased, all approvals are frequently reset even though the actual code changes are identical. This is caused by git patch-id including 3 context lines in its hash. When a rebase shifts the merge base, surrounding context lines change, producing a different patch-ID and triggering a false-positive approval reset.

Real-world impact (DevEx report): !225247 (merged) had all 5 approvals by Mar 5. A rebase on Mar 6 reset all approvals. The MR finally merged Mar 9 — 4 calendar days after approval, with ~10.5 hours of wasted CI compute and 4 dependent MRs blocked.

This is tracked as &544 (Smarter approval resets) — open since 2018, due Mar 13 2026.

Solution

Two independent, feature-flagged layers that preserve security guarantees:

Layer 1: Context-free patch-ID (Gitaly + Rails)

Compute git patch-id from a diff with zero context lines (-U0). The hash then depends only on actual added/removed lines, not surrounding code that shifts on rebase.

  • Gitaly side: !8535 adds bool context_free to GetPatchIDRequest
  • Rails side: passes context_free: true through the client chain when the approval_reset_zero_context_patch_id feature flag is enabled
  • Security: The hash still reflects all code changes. A malicious commit produces a different patch-ID.

Layer 2: Rebase detection fast path (Rails)

Detects when a push is purely a rebase (no new functional commits) and skips the approval reset. Detection requires ALL of:

  1. Force push (old head is not ancestor of new head)
  2. Same number of commits with matching author + date + message
  3. Patch-ID equivalence between old and new diffs (content verification)

Step 3 is critical. Without it, an attacker could forge commit metadata while injecting different code.

  • Gated behind the approval_reset_rebase_detection feature flag
  • Errors are rescued and logged; the safe default (reset approvals) is used on failure

What changed

Source files (6 files)

  • lib/gitlab/gitaly_client/commit_service.rbcontext_free: keyword with proto reflection for transition safety
  • lib/gitlab/git/repository.rb — pass-through
  • app/models/repository.rb — pass-through
  • app/models/merge_request_diff.rbset_patch_id_sha uses context_free: true when flag enabled
  • ee/app/services/merge_requests/reset_approvals_service.rbrebase_only_push? early return with commits_match? and diffs_equivalent?
  • Feature flags: approval_reset_zero_context_patch_id, approval_reset_rebase_detection

Test files (7 files)

  • spec/lib/gitlab/gitaly_client/commit_service_spec.rbcontext_free param with/without proto support
  • spec/lib/gitlab/git/repository_spec.rb — pass-through
  • spec/models/repository_spec.rb — pass-through
  • spec/models/merge_request_diff_spec.rb — feature flag toggles context_free
  • ee/spec/services/merge_requests/reset_approvals_service_spec.rb — rebase preserves approvals; extra commits reset; forged metadata with different content resets; error handling falls through safely
  • ee/spec/services/merge_requests/reset_approvals_service_integration_spec.rb — integration tests with real git operations for rebase detection and context-free patch-ID

Documentation (3 files)

  • doc/user/project/merge_requests/approvals/settings.md — user-facing section on improved rebase handling
  • doc/development/smarter_approval_resets.md — developer guide with architecture, deployment order, and E2E testing plan
  • doc/development/gitaly_patches/context_free_patch_id.md — Gitaly proto/implementation reference

Deployment order

  1. Gitaly MR (!8535) ships proto + Go changes
  2. Gitaly gem bump in gitlab-org/gitlab
  3. This Rails MR ships
  4. Enable Layer 1: Feature.enable(:approval_reset_zero_context_patch_id)
  5. Monitor approval reset rates
  6. Enable Layer 2: Feature.enable(:approval_reset_rebase_detection)
  7. Monitor error tracking for rebase detection failures

Merge request reports

Loading