Use Gitaly exclude_patterns for unprotected branches pagination

What does this MR do and why?

Adds exclude_patterns support to the Gitaly ListRefs RPC wrapper and introduces a new paginated unprotectedBranches GraphQL field that delegates branch filtering to Gitaly instead of loading all branches into Ruby.

Previously, the protectableBranches field fetched all branch names from Gitaly, then subtracted protected branches in Ruby. On projects with thousands of branches this was slow, memory-intensive, and not paginated. The old approach also had a known correctness gap: if enough consecutive branches were protected, has_next_page could return false prematurely.

Now, exact-match protected branch names are passed as exclude_patterns to Gitaly, which handles filtering and pagination server-side in a single RPC call per page. Wildcard-protected branches are intentionally not excluded (matching the existing protectableBranches semantics — branches eligible for individual protection are still shown even if they match a wildcard rule).

References

How to set up and validate locally

Prerequisites: Your GDK's Gitaly must be >= 19.1 for exclude_patterns to work server-side. Run gdk update and gdk restart if needed. Verify with: <gdk-root>/gitaly/_build/bin/gitaly --version

  1. Pick any project with a repository that has multiple branches. Protect one or more branches using names that exist in your repo — create one exact-match and one wildcard to test both cases:
    # In rails console:
    project = Project.find_by_full_path('<your/project>')
    # Replace these with branch names from your repo
    ProtectedBranch.create!(project: project, name: '<exact-branch-name>')   # exact-match: will be excluded
    ProtectedBranch.create!(project: project, name: '<prefix>*')             # wildcard: will NOT be excluded
  2. Query the new field in the GraphQL explorer (http://localhost:3000/-/graphql-explorer):
    {
      project(fullPath: "<your/project>") {
        unprotectedBranches(first: 5) {
          pageInfo {
            hasNextPage
            endCursor
          }
          nodes
        }
      }
    }
  3. Verify:
    • The exact-match protected branch is not in the results
    • Branches matching the wildcard pattern are still in the results
    • hasNextPage and endCursor are present when there are more results
  4. Test pagination by using the endCursor from the previous response:
    {
      project(fullPath: "<your/project>") {
        unprotectedBranches(first: 5, after: "<endCursor value>") {
          pageInfo {
            hasNextPage
            endCursor
          }
          nodes
        }
      }
    }
    Verify that the exact-match protected branch does not appear on subsequent pages either, and that there are no duplicate results across pages.

Multi-version compatibility

This MR bumps the gitaly gem and uses the new exclude_patterns field on ListRefsRequest (added in Gitaly 19.1). The field is only used by the new unprotectedBranches GraphQL field, so there is no risk to existing functionality. The exclude_patterns field is additive — older Gitaly versions will ignore it and return unfiltered results. The new GraphQL field is marked as experiment (19.3), so it can be iterated on freely.

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.

Edited by Gloria Odipo

Merge request reports

Loading
Loading