Branch protection: the most specific matching rule applies

a646eda · Gabe Morell · 2026-11-04 21:31

6 files +53 -2
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/branch_protections.ex
+19 −2
@@ -47,12 +47,29 @@ defmodule GitGud.BranchProtections do
47 47
48 48 def delete_rule(%BranchProtection{} = p), do: Repo.delete(p)
49 49
50 − @doc "Fetch the first matching rule for a branch (by glob)."
50 + @doc """
51 + The rule for a branch: of the rules whose glob matches it, the most
52 + specific (`specificity/1`), so `main` beats `ma*` beats `*` whatever
53 + order they were made in.
54 + """
51 55 def rule_for_branch(%Repository{id: rid}, branch) when is_binary(branch) do
52 56 # Patterns are globs, matched in Elixir; a repository has a handful of rules.
53 57 # credo:disable-for-lines:2 ExSlop.Check.Warning.RepoAllThenFilter
54 58 Repo.all(from p in BranchProtection, where: p.repository_id == ^rid)
55 − |> Enum.find(&matches_pattern?(&1, branch))
59 + |> Enum.filter(&matches_pattern?(&1, branch))
60 + |> Enum.min_by(&specificity/1, fn -> nil end)
61 + end
62 +
63 + # Smaller sorts first: an exact name, then more literal characters, then
64 + # fewer `*` (a `?` still pins one character), then the pattern and id,
65 + # so two equally specific rules always resolve the same way.
66 + defp specificity(%BranchProtection{pattern: pat, id: id}) do
67 + chars = String.to_charlist(pat)
68 + stars = Enum.count(chars, &(&1 == ?*))
69 + marks = Enum.count(chars, &(&1 == ??))
70 + exact = if stars + marks == 0, do: 0, else: 1
71 +
72 + {exact, -(length(chars) - stars - marks), stars, pat, id}
56 73 end
57 74
58 75 defp matches_pattern?(%BranchProtection{pattern: pat}, branch) do
modified test/git_gud/branch_protections_test.exs
+34 −0
@@ -27,6 +27,40 @@ defmodule GitGud.BranchProtectionsTest do
27 27 BranchProtections.check_ref_update(repo, "main", fake_old, fake_new)
28 28 end
29 29
30 + describe "rule_for_branch/2: the most specific matching rule" do
31 + test "an exact name beats any glob, whatever order they were made in", %{repo: repo} do
32 + {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "*"})
33 + {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "ma*"})
34 + {:ok, exact} = BranchProtections.create_rule(repo, %{"pattern" => "main"})
35 + {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "m?in"})
36 +
37 + assert BranchProtections.rule_for_branch(repo, "main").id == exact.id
38 + end
39 +
40 + test "more literal characters win among globs", %{repo: repo} do
41 + {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "*"})
42 + {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "release/*"})
43 + {:ok, v} = BranchProtections.create_rule(repo, %{"pattern" => "release/v*"})
44 +
45 + assert BranchProtections.rule_for_branch(repo, "release/v1").id == v.id
46 + assert BranchProtections.rule_for_branch(repo, "release/x").pattern == "release/*"
47 + assert BranchProtections.rule_for_branch(repo, "dev").pattern == "*"
48 + end
49 +
50 + test "with as many literals, `?` beats `*`", %{repo: repo} do
51 + {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "v*"})
52 + {:ok, one} = BranchProtections.create_rule(repo, %{"pattern" => "v?"})
53 +
54 + assert BranchProtections.rule_for_branch(repo, "v1").id == one.id
55 + end
56 +
57 + test "nil when nothing matches", %{repo: repo} do
58 + {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "release/*"})
59 +
60 + assert BranchProtections.rule_for_branch(repo, "main") == nil
61 + end
62 + end
63 +
30 64 test "blocks deletion of a protected branch", %{repo: repo} do
31 65 {:ok, _} = BranchProtections.create_rule(repo, %{"pattern" => "main"})
32 66

Parents: fec4951