ref:43d82342c06f1a94508033823483ae8caaccfb90

Add update and post-receive hook support (#5)

## Summary - Add `update_hook` (per-ref, can reject individual refs) and `post_receive_hook` (after all updates, for notifications/CI triggers) to receive-pack - Follows the same pluggable function pattern as the existing `pre_receive_hook` Closes #9 ## Hook execution order 1. `pre_receive_hook(commands)` — all-or-nothing, rejects entire push 2. `update_hook(ref, old_sha, new_sha)` — per-ref, rejection blocks only that ref 3. Ref updates applied 4. `post_receive_hook(changes)` — receives full change list with statuses, failures logged but don't affect result ## Test plan - [x] update_hook called per-ref with correct arguments - [x] update_hook rejection blocks only the affected ref, others succeed - [x] post_receive_hook called with full change list including statuses - [x] post_receive_hook includes rejected refs in changes - [x] post_receive_hook failure doesn't affect push result - [x] post_receive_hook not called when pre_receive_hook rejects - [x] No hooks configured works as before - [x] 556 tests pass, dialyzer clean
SHA: 43d82342c06f1a94508033823483ae8caaccfb90
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-09 20:13
Parents: a3f0318
2 files changed +330 -4
Type
lib/ex_git_objectstore/protocol/receive_pack.ex +62 −4
@@ -48,9 +48,24 @@
new_sha: String.t()
}
@type update_hook :: (ref :: String.t(), old_sha :: String.t(), new_sha :: String.t() ->
:ok | {:error, term()})
@type post_receive_hook :: ([
%{
ref: String.t(),
old_sha: String.t(),
new_sha: String.t(),
status: :ok | {:error, term()}
}
] ->
:ok | {:error, term()})
@type state :: %__MODULE__{
repo: Repo.t(),
pre_receive_hook: (Repo.t(), [command()] -> :ok | {:error, term()}) | nil,
update_hook: update_hook() | nil,
post_receive_hook: post_receive_hook() | nil,
phase: :advertise | :commands | :pack | :done,
commands: [command()],
client_caps: MapSet.t(),
@@ -62,6 +77,8 @@
defstruct [
:repo,
:pre_receive_hook,
:update_hook,
:post_receive_hook,
phase: :advertise,
commands: [],
client_caps: MapSet.new(),
@@ -80,8 +97,14 @@
@dialyzer {:no_opaque, init: 2}
@spec init(Repo.t(), keyword()) :: {binary(), state()}
def init(%Repo{} = repo, opts \\ []) do
pre_receive_hook = Keyword.get(opts, :pre_receive_hook)
state = %__MODULE__{repo: repo, phase: :commands, pre_receive_hook: pre_receive_hook}
state = %__MODULE__{
repo: repo,
phase: :commands,
pre_receive_hook: Keyword.get(opts, :pre_receive_hook),
update_hook: Keyword.get(opts, :update_hook),
post_receive_hook: Keyword.get(opts, :post_receive_hook)
}
advert = build_advertisement(repo)
{advert, state}
end
@@ -468,19 +491,54 @@
defp do_process_ref_updates(state) do
results =
Enum.map(state.commands, fn cmd ->
result = apply_ref_command(state.repo, cmd)
{cmd.ref, result}
case run_update_hook(state.update_hook, cmd) do
:ok ->
result = apply_ref_command(state.repo, cmd)
{cmd.ref, result}
{:error, _} = err ->
{cmd.ref, err}
end
end)
# Track whether any ref updates failed
any_errors? = Enum.any?(results, fn {_ref, r} -> match?({:error, _}, r) end)
overall_result = if any_errors?, do: {:error, :some_refs_failed}, else: :ok
# Call post_receive hook with change list (failures logged, don't affect result)
changes =
Enum.zip(state.commands, results)
|> Enum.map(fn {cmd, {_ref, status}} ->
%{ref: cmd.ref, old_sha: cmd.old_sha, new_sha: cmd.new_sha, status: status}
end)
run_post_receive_hook(state.post_receive_hook, changes)
# Only send report-status if client requested it (or if no commands/caps parsed)
if MapSet.member?(state.client_caps, "report-status") or MapSet.size(state.client_caps) == 0 do
report = build_report(results)
{report, %{state | phase: :done, result: overall_result}}
else
{<<>>, %{state | phase: :done, result: overall_result}}
end
end
defp run_update_hook(nil, _cmd), do: :ok
defp run_update_hook(hook, cmd) when is_function(hook, 3) do
hook.(cmd.ref, cmd.old_sha, cmd.new_sha)
end
defp run_post_receive_hook(nil, _changes), do: :ok
defp run_post_receive_hook(hook, changes) when is_function(hook, 1) do
case hook.(changes) do
:ok ->
:ok
{:error, reason} ->
require Logger
Logger.warning("post_receive hook failed: #{inspect(reason)}")
:ok
end
end