neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Branch protection: the most specific matching rule applies
a646eda · Gabe Morell · 2026-11-04 21:31
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