3.0 KiB · text 5af55d9
defmodule GitGudWeb.PreReceiveController do
@moduledoc """
Pre-receive hook evaluator. Receives the same `<old> <new> <ref>` stdin
as `post-receive`, consults `GitGud.BranchProtections`, and replies:
OK
\n
on success, or:
DENY
<multi-line human-readable reason>
on rejection. The shell script translates non-OK responses into a
non-zero exit, which makes `git push` abort and report the reason to
the pushing client.
"""
use GitGudWeb, :controller
import Ecto.Query, warn: false
alias GitGud.BranchProtections
alias GitGud.Git
alias GitGud.Repo
alias GitGud.Repositories.Repository
def pre_receive(conn, _params) do
case Plug.Conn.get_req_header(conn, "x-repo-path") do
[path | _] ->
{:ok, body, conn} = Plug.Conn.read_body(conn, length: 1_000_000)
case find_repo(path) do
%Repository{} = repo ->
case evaluate(repo, body) do
:ok -> text(conn, "OK\n")
{:deny, reasons} -> text(conn, "DENY\n" <> Enum.join(reasons, "\n") <> "\n")
end
_ ->
send_resp(conn, 404, "unknown repo\n")
end
_ ->
send_resp(conn, 400, "missing x-repo-path\n")
end
end
defp find_repo(path),
do: Repo.one(from r in Repository, where: r.disk_path == ^path)
defp evaluate(repo, stdin) do
triples =
stdin
|> String.split("\n", trim: true)
|> Enum.map(&parse_line/1)
|> Enum.reject(&is_nil/1)
failures =
Enum.flat_map(triples, fn {old, new, ref} ->
case check_one(repo, ref, old, new) do
:ok -> []
{:error, reason} -> [format_reason(ref, reason)]
end
end)
case failures do
[] -> :ok
list -> {:deny, list}
end
end
defp parse_line(line) do
case String.split(line, " ", parts: 3) do
[old, new, ref] when byte_size(old) == 40 and byte_size(new) == 40 ->
with {:ok, old_sha} <- decode_sha(old),
{:ok, new_sha} <- decode_sha(new) do
{old_sha, new_sha, ref}
else
_ -> nil
end
_ ->
nil
end
end
defp decode_sha(hex) do
case Git.from_hex(hex) do
{:ok, sha} -> {:ok, sha}
:error -> :error
end
end
defp check_one(repo, "refs/heads/" <> branch, old_sha, new_sha) do
BranchProtections.check_ref_update(repo, branch, old_sha, new_sha)
end
# Tags and other refs are unrestricted for now.
defp check_one(_repo, _ref, _old, _new), do: :ok
defp format_reason(ref, :force_push_blocked),
do: "#{ref}: force-push blocked by branch protection"
defp format_reason(ref, :deletion_blocked),
do: "#{ref}: deletion blocked by branch protection"
defp format_reason(ref, :approvals_required),
do: "#{ref}: direct push blocked; open a PR with the required approvals"
defp format_reason(ref, {:approvals_required, needed, got}),
do: "#{ref}: #{got}/#{needed} required approvals"
defp format_reason(ref, other),
do: "#{ref}: rejected (#{inspect(other)})"
end