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
test/ex_git_objectstore/protocol/receive_pack_hooks_test.exs +268 −0
@@ -1,0 +1,268 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.ReceivePackHooksTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack}
alias ExGitObjectstore.Test.RepoHelper
@zero_sha String.duplicate("0", 40)
# -- Helpers --
defp build_repo_with_commit do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("hello\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1000000000 +0000",
committer: "Test <test@test.com> 1000000000 +0000",
message: "initial\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
%{repo: repo, commit_sha: commit_sha, tree_sha: tree_sha, blob_sha: blob_sha}
end
defp push_command(old_sha, new_sha, ref) do
cmd_line = "#{old_sha} #{new_sha} #{ref}\0report-status"
PktLine.encode(cmd_line) <> PktLine.flush()
end
defp push_commands(cmds) do
{first, rest} =
case cmds do
[{old, new, ref} | t] -> {PktLine.encode("#{old} #{new} #{ref}\0report-status"), t}
end
rest_lines =
Enum.map(rest, fn {old, new, ref} ->
PktLine.encode("#{old} #{new} #{ref}")
end)
IO.iodata_to_binary([first | rest_lines] ++ [PktLine.flush()])
end
# Minimal valid pack with 0 objects (header + SHA checksum)
defp empty_pack do
header = <<"PACK", 2::unsigned-big-32, 0::unsigned-big-32>>
checksum = :crypto.hash(:sha, header)
header <> checksum
end
defp assert_ref_ok(response, ref) do
assert response =~ "ok #{ref}"
end
defp assert_ref_ng(response, ref) do
assert response =~ "ng #{ref}"
end
# -- update hook tests --
describe "update hook" do
test "called per-ref, can accept individual refs" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
test_pid = self()
update_hook = fn ref, _old_sha, _new_sha ->
send(test_pid, {:update_called, ref})
:ok
end
{_advert, state} = ReceivePack.init(repo, update_hook: update_hook)
data = push_command(@zero_sha, sha, "refs/heads/main")
{response, _state} = ReceivePack.feed(state, data <> empty_pack())
assert_received {:update_called, "refs/heads/main"}
assert_ref_ok(response, "refs/heads/main")
end
test "rejection blocks only the affected ref" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
update_hook = fn ref, _old_sha, _new_sha ->
if ref == "refs/heads/blocked" do
{:error, "branch is protected"}
else
:ok
end
end
{_advert, state} = ReceivePack.init(repo, update_hook: update_hook)
data =
push_commands([
{@zero_sha, sha, "refs/heads/allowed"},
{@zero_sha, sha, "refs/heads/blocked"}
])
{response, _state} = ReceivePack.feed(state, data <> empty_pack())
assert_ref_ok(response, "refs/heads/allowed")
assert_ref_ng(response, "refs/heads/blocked")
# allowed ref was actually created
assert {:ok, ^sha} = Ref.get(repo, "refs/heads/allowed")
# blocked ref was not created
assert {:error, :not_found} = Ref.get(repo, "refs/heads/blocked")
end
test "called with correct arguments" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
test_pid = self()
update_hook = fn ref, old_sha, new_sha ->
send(test_pid, {:update_args, ref, old_sha, new_sha})
:ok
end
{_advert, state} = ReceivePack.init(repo, update_hook: update_hook)
data = push_command(@zero_sha, sha, "refs/heads/main")
{_response, _state} = ReceivePack.feed(state, data <> empty_pack())
assert_received {:update_args, "refs/heads/main", @zero_sha, ^sha}
end
test "no update hook means all refs accepted" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
{_advert, state} = ReceivePack.init(repo)
data = push_command(@zero_sha, sha, "refs/heads/main")
{response, _state} = ReceivePack.feed(state, data <> empty_pack())
assert_ref_ok(response, "refs/heads/main")
end
end
# -- post_receive hook tests --
describe "post_receive hook" do
test "called after all refs updated with list of changes" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
test_pid = self()
post_receive_hook = fn changes ->
send(test_pid, {:post_receive, changes})
:ok
end
{_advert, state} = ReceivePack.init(repo, post_receive_hook: post_receive_hook)
data = push_command(@zero_sha, sha, "refs/heads/main")
{_response, _state} = ReceivePack.feed(state, data <> empty_pack())
assert_received {:post_receive, changes}
assert [%{ref: "refs/heads/main", old_sha: @zero_sha, new_sha: ^sha, status: :ok}] = changes
end
test "includes rejected refs in changes list" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
test_pid = self()
update_hook = fn ref, _old, _new ->
if ref == "refs/heads/blocked", do: {:error, "nope"}, else: :ok
end
post_receive_hook = fn changes ->
send(test_pid, {:post_receive, changes})
:ok
end
{_advert, state} =
ReceivePack.init(repo,
update_hook: update_hook,
post_receive_hook: post_receive_hook
)
data =
push_commands([
{@zero_sha, sha, "refs/heads/ok"},
{@zero_sha, sha, "refs/heads/blocked"}
])
{_response, _state} = ReceivePack.feed(state, data <> empty_pack())
assert_received {:post_receive, changes}
assert length(changes) == 2
ok_change = Enum.find(changes, &(&1.ref == "refs/heads/ok"))
blocked_change = Enum.find(changes, &(&1.ref == "refs/heads/blocked"))
assert ok_change.status == :ok
assert match?({:error, _}, blocked_change.status)
end
test "failure is logged but does not affect push result" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
post_receive_hook = fn _changes ->
{:error, "webhook failed"}
end
{_advert, state} = ReceivePack.init(repo, post_receive_hook: post_receive_hook)
data = push_command(@zero_sha, sha, "refs/heads/main")
{response, state} = ReceivePack.feed(state, data <> empty_pack())
# Push should still succeed even though post_receive failed
assert_ref_ok(response, "refs/heads/main")
assert {:ok, ^sha} = Ref.get(repo, "refs/heads/main")
# The state result should reflect success (post_receive doesn't affect it)
assert state.result == :ok
end
test "not called when pre_receive_hook rejects" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
test_pid = self()
pre_receive_hook = fn _commands -> {:error, "rejected"} end
post_receive_hook = fn _changes ->
send(test_pid, :post_receive_called)
:ok
end
{_advert, state} =
ReceivePack.init(repo,
pre_receive_hook: pre_receive_hook,
post_receive_hook: post_receive_hook
)
data = push_command(@zero_sha, sha, "refs/heads/main")
{_response, _state} = ReceivePack.feed(state, data <> empty_pack())
refute_received :post_receive_called
end
test "no post_receive hook is fine" do
%{repo: repo, commit_sha: sha} = build_repo_with_commit()
{_advert, state} = ReceivePack.init(repo)
data = push_command(@zero_sha, sha, "refs/heads/main")
{response, _state} = ReceivePack.feed(state, data <> empty_pack())
assert_ref_ok(response, "refs/heads/main")
end
end
end