defmodule GitGud.BranchProtections do
@moduledoc """
Rules applied to branch refs at write time.
Enforced in two places:
* `GitGudWeb.Plugs.GitSmartHttp` — pre-receive-style hook that
inspects the proposed ref update before forwarding to
`git http-backend`.
* `GitGud.PullRequests.merge!/3` — refuses to merge when the target
ref is protected and the PR doesn't satisfy the rule.
"""
import Ecto.Query, warn: false
alias GitGud.BranchProtections.BranchProtection
alias GitGud.PullRequests.PrReview
alias GitGud.Repo
alias GitGud.Repositories.Repository
def list_rules(%Repository{id: rid}) do
BranchProtection
|> where([p], p.repository_id == ^rid)
|> order_by([p], asc: p.pattern)
|> Repo.all()
end
def get_rule!(id), do: Repo.get!(BranchProtection, id)
def create_rule(%Repository{id: rid}, attrs) do
%BranchProtection{repository_id: rid}
|> BranchProtection.changeset(attrs)
|> Repo.insert()
end
def update_rule(%BranchProtection{} = p, attrs) do
p |> BranchProtection.changeset(attrs) |> Repo.update()
end
def delete_rule(%BranchProtection{} = p), do: Repo.delete(p)
@doc "Fetch the first matching rule for a branch (by glob)."
def rule_for_branch(%Repository{id: rid}, branch) when is_binary(branch) do
Repo.all(from p in BranchProtection, where: p.repository_id == ^rid)
|> Enum.find(&matches_pattern?(&1, branch))
end
defp matches_pattern?(%BranchProtection{pattern: pat}, branch) do
regex =
pat
|> Regex.escape()
|> String.replace("\\*", ".*")
|> String.replace("\\?", ".")
Regex.match?(~r/^#{regex}$/, branch)
end
@doc """
Evaluate whether a proposed ref update is allowed.
`old_sha` / `new_sha` are raw 20-byte binaries. `pr` is optional;
when present (i.e., the update is a PR merge), reviews are counted.
"""
def check_ref_update(%Repository{} = repo, branch, old_sha, new_sha, opts \\ []) do
case rule_for_branch(repo, branch) do
nil -> :ok
rule -> evaluate(rule, repo, old_sha, new_sha, opts)
end
end
defp evaluate(_rule, _repo, _old, new_sha, _opts) when is_nil(new_sha), do: :ok
defp evaluate(rule, _repo, _old, <<0::size(160)>>, _opts) do
if rule.allow_deletions, do: :ok, else: {:error, :deletion_blocked}
end
defp evaluate(rule, repo, old_sha, new_sha, opts) do
cond do
force_push?(repo.disk_path, old_sha, new_sha) and not rule.allow_force_push ->
{:error, :force_push_blocked}
rule.required_approvals > 0 ->
check_approvals(rule, opts)
true ->
:ok
end
end
defp force_push?(_path, nil, _), do: false
defp force_push?(_path, <<0::size(160)>>, _), do: false
defp force_push?(path, old_sha, new_sha) do
# Not a force-push iff old_sha is an ancestor of new_sha.
case System.cmd(
"git",
[
"-C",
path,
"merge-base",
"--is-ancestor",
GitGud.Git.to_hex(old_sha),
GitGud.Git.to_hex(new_sha)
],
stderr_to_stdout: true
) do
{_, 0} -> false
_ -> true
end
end
defp check_approvals(rule, opts) do
pr_id = Keyword.get(opts, :pull_request_id)
if is_nil(pr_id) do
# A direct push (no PR) can't have approvals.
{:error, :approvals_required}
else
approvals =
Repo.aggregate(
from(r in PrReview, where: r.pull_request_id == ^pr_id and r.state == "approved"),
:count
)
if approvals >= rule.required_approvals,
do: :ok,
else: {:error, {:approvals_required, rule.required_approvals, approvals}}
end
end
end
3.6 KiB · text
5af55d9