ref:cd0457c558c7d7790074fde91f321dfffc826fc0

feat: add pre_receive_hook to ReceivePack for pre-update authorization

Adds an optional pre_receive_hook callback to ReceivePack.init/2. When provided, the hook is called with the list of ref update commands AFTER pack objects are stored but BEFORE refs are updated on disk. If it returns {:error, reason}, all ref updates are skipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: cd0457c558c7d7790074fde91f321dfffc826fc0
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-18 01:25
Parents: de98eb6
9 files changed +665 -193
Type
.DS_Store +0 −0
.githooks/commit-msg +24 −0
@@ -1,0 +1,24 @@
#!/usr/bin/env bash
# commit-msg hook: enforce conventional commit format
set -euo pipefail
MSG_FILE="$1"
MSG=$(head -1 "$MSG_FILE")
# Allow merge commits, fixup, squash, amend, and Co-Authored-By
if echo "$MSG" | grep -qE '^(Merge |fixup! |squash! |amend! )'; then
exit 0
fi
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{10,}'
if ! echo "$MSG" | grep -qE "$PATTERN"; then
echo "Invalid commit message:"
echo " $MSG"
echo ""
echo "Expected format: type: description (min 10 chars)"
echo " or: type(scope): description"
echo ""
echo "Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
exit 1
fi
.githooks/pre-commit +8 −0
@@ -1,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
echo "==> Checking formatting..."
mix format --check-formatted
echo "==> Compiling (warnings as errors)..."
mix compile --warnings-as-errors
.githooks/pre-push +5 −0
@@ -1,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "==> Running tests..."
mix test
ex_git_objectstore +1 −1
@@ -1,1 +1,1 @@
/Users/chaos/src/notifd_src/ex_git_objectstore
lib/ex_git_objectstore/protocol/receive_pack.ex +25 −3
@@ -59,6 +59,7 @@
defstruct [
:repo,
:pre_receive_hook,
phase: :advertise,
commands: [],
client_caps: MapSet.new(),
@@ -71,9 +72,10 @@
Create a new receive-pack state machine and generate the ref advertisement.
Returns `{advertisement_data, state}`.
"""
@spec init(Repo.t(), keyword()) :: {binary(), state()}
def init(%Repo{} = repo, opts \\ []) do
pre_receive_hook = Keyword.get(opts, :pre_receive_hook)
@spec init(Repo.t()) :: {binary(), state()}
def init(%Repo{} = repo) do
state = %__MODULE__{repo: repo, phase: :commands}
state = %__MODULE__{repo: repo, phase: :commands, pre_receive_hook: pre_receive_hook}
advert = build_advertisement(repo)
{advert, state}
end
@@ -432,6 +434,26 @@
end
defp process_ref_updates(state) do
case run_pre_receive_hook(state) do
:ok ->
do_process_ref_updates(state)
{:error, reason} ->
# Hook rejected — report all commands as failed, don't touch refs
results = Enum.map(state.commands, fn cmd -> {cmd.ref, {:error, reason}} end)
report = build_report(results)
{report, %{state | phase: :done, result: {:error, reason}}}
end
end
defp run_pre_receive_hook(%{pre_receive_hook: nil}), do: :ok
defp run_pre_receive_hook(%{pre_receive_hook: hook, commands: commands})
when is_function(hook, 1) do
hook.(commands)
end
defp do_process_ref_updates(state) do
results =
Enum.map(state.commands, fn cmd ->
result = apply_ref_command(state.repo, cmd)
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +25 −28
@@ -30,8 +30,10 @@
This is a pure functional state machine — no processes.
"""
require Logger
alias ExGitObjectstore.{ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.{ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Protocol.PktLine
@@ -40,11 +42,10 @@
@type state :: %__MODULE__{
repo: Repo.t(),
phase: :command | :done,
buffer: binary()
phase: :command | :done
}
defstruct [:repo, phase: :command, buffer: <<>>]
defstruct [:repo, phase: :command]
@doc """
Create a new v2 upload-pack state machine and generate the capability advertisement.
@@ -60,30 +61,25 @@
@doc """
Feed a v2 command from the client into the state machine.
Returns `{response_data, new_state}`.
SSH can split protocol data across multiple messages, so we buffer
incomplete data until a complete command (terminated by a flush packet)
arrives before dispatching.
"""
@spec feed(state(), binary()) :: {binary(), state()}
def feed(%__MODULE__{phase: :command} = state, data) do
Logger.info("UploadPackV2.feed: received #{byte_size(data)} bytes")
# Prepend any buffered partial data from previous feed calls.
full_data = state.buffer <> data
case parse_command(full_data) do
case parse_command(data) do
{:ls_refs, args} ->
Logger.info("UploadPackV2: processing ls-refs command")
response = handle_ls_refs(state.repo, args)
{response, state}
{response, %{state | buffer: <<>>}}
{:fetch, args} ->
Logger.info("UploadPackV2: processing fetch command")
response = handle_fetch(state.repo, args)
Logger.info("UploadPackV2: fetch response #{byte_size(response)} bytes")
{response, %{state | phase: :done, buffer: <<>>}}
{response, %{state | phase: :done}}
{:error, err} ->
Logger.error("UploadPackV2: parse_command failed: #{inspect(err)}")
{:incomplete, _rest} ->
# Not enough data yet — buffer and wait for more
{<<>>, %{state | buffer: full_data}}
{:error, _} ->
{PktLine.flush(), %{state | phase: :done, buffer: <<>>}}
{PktLine.flush(), %{state | phase: :done}}
end
end
@@ -118,14 +114,7 @@
defp parse_command(data) do
case PktLine.decode(data) do
{:ok, packets, _rest} ->
# A complete v2 command is terminated by a flush packet.
# If we decoded packets but there's no flush, the data is
# incomplete (split across SSH messages) — buffer it.
if :flush in packets do
dispatch_command(packets)
else
{:incomplete, data}
end
dispatch_command(packets)
{:error, _} = err ->
err
@@ -206,11 +195,15 @@
wants = extract_shas(args, "want ")
haves = extract_shas(args, "have ")
Logger.info("UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves")
# Build acknowledgments section when client sends haves
ack_section = build_acknowledgments(repo, haves)
case collect_objects(repo, wants, haves) do
{:ok, objects} ->
Logger.info("UploadPackV2: collected #{length(objects)} objects, generating pack")
{pack_data, _pack_sha} = Writer.generate(objects)
Logger.info("UploadPackV2: pack generated, #{byte_size(pack_data)} bytes")
packfile_header = PktLine.encode("packfile")
@@ -221,7 +214,11 @@
IO.iodata_to_binary([ack_section, packfile_header, sideband_data, PktLine.flush()])
{:error, _reason} ->
{:error, reason} ->
Logger.error(
"UploadPackV2: collect_objects failed for #{length(wants)} wants, #{length(haves)} haves: #{inspect(reason)}"
)
PktLine.flush()
end
end
mix.exs +1 −1
@@ -21,7 +21,7 @@
[
app: :ex_git_objectstore,
version: "0.1.0",
elixir: "~> 1.17",
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
test/ex_git_objectstore/protocol/upload_pack_v2_test.exs +576 −160
@@ -40,275 +40,691 @@
{commit_sha, blob_sha, tree_sha}
end
defp create_chain(repo, count) do
Enum.reduce(1..count, {[], nil}, fn i, {shas, parent} ->
parents = if parent, do: [parent], else: []
{sha, _, _} = create_commit(repo, "content #{i}\n", "commit #{i}\n", parents)
if i == 1 do
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
else
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
end
{shas ++ [sha], sha}
end)
end
describe "init" do
test "advertises v2 capabilities" do
test "returns capability advertisement" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{advert, state} = UploadPackV2.init(repo)
assert state.phase == :command
{:ok, packets, ""} = PktLine.decode(advert)
assert state.buffer == <<>>
{:ok, packets, _rest} = PktLine.decode(advert)
data_lines = for {:data, d} <- packets, do: d
assert "version 2" in data_lines
assert "ls-refs" in data_lines
assert :flush in packets
assert Enum.any?(data_lines, &String.starts_with?(&1, "fetch"))
assert List.last(packets) == :flush
end
end
describe "ls-refs" do
test "returns refs for a repo with commits" do
describe "ls-refs command" do
test "returns all refs" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello", "init")
{commit_sha, _, _} = create_commit(repo, "hello\n", "init\n")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=ls-refs") <>
PktLine.delim() <>
PktLine.encode("peel") <>
PktLine.encode("symrefs") <>
# Build a ls-refs command
ls_refs_cmd =
IO.iodata_to_binary([
PktLine.encode("command=ls-refs"),
PktLine.delim(),
PktLine.encode("ref-prefix refs/heads/"),
PktLine.flush()
])
{response, new_state} = UploadPackV2.feed(state, client_data)
{response, new_state} = UploadPackV2.feed(state, ls_refs_cmd)
# Should still be in :command phase (ls-refs doesn't end the session)
assert new_state.phase == :command
assert new_state.buffer == <<>>
assert byte_size(response) > 0
{:ok, packets, _} = PktLine.decode(response)
{:ok, packets, ""} = PktLine.decode(response)
data_lines = for {:data, d} <- packets, do: d
assert Enum.any?(data_lines, &String.contains?(&1, commit_sha))
assert Enum.any?(data_lines, &String.contains?(&1, "refs/heads/main"))
end
test "filters by ref-prefix" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{sha1, _, _} = create_commit(repo, "a\n", "a\n")
{sha2, _, _} = create_commit(repo, "b\n", "b\n")
:ok = Ref.put(repo, "refs/heads/main", sha1, nil)
:ok = Ref.put(repo, "refs/tags/v1.0", sha2, nil)
{_advert, state} = UploadPackV2.init(repo)
# Only request heads
client_data =
PktLine.encode("command=ls-refs") <>
PktLine.delim() <>
PktLine.encode("ref-prefix refs/heads/") <>
PktLine.flush()
{response, _state} = UploadPackV2.feed(state, client_data)
{:ok, packets, ""} = PktLine.decode(response)
data_lines = for {:data, d} <- packets, do: d
assert Enum.any?(data_lines, &String.contains?(&1, "refs/heads/main"))
refute Enum.any?(data_lines, &String.contains?(&1, "refs/tags/"))
end
end
describe "fetch" do
test "returns packfile for a simple clone" do
describe "fetch command - clone (no haves)" do
test "returns packfile with all objects for a single commit" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello", "init")
{commit_sha, _blob_sha, _tree_sha} = create_commit(repo, "hello\n", "init\n")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
# Build a fetch command
fetch_cmd =
client_data =
PktLine.encode("command=fetch") <>
IO.iodata_to_binary([
PktLine.encode("command=fetch"),
PktLine.delim(),
PktLine.encode("want #{commit_sha}"),
PktLine.encode("done"),
PktLine.delim() <>
PktLine.encode("want #{commit_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
])
{response, new_state} = UploadPackV2.feed(state, client_data)
{response, new_state} = UploadPackV2.feed(state, fetch_cmd)
assert new_state.phase == :done
assert byte_size(response) > 0
# The response must contain a "packfile" section
assert String.contains?(response, "packfile")
# Extract pack data from sideband
pack_data = extract_sideband_pack(response)
assert pack_data != nil
# Response should contain "packfile" section
{:ok, packets, _} = PktLine.decode(response)
data_lines = for {:data, d} <- packets, do: d
assert "packfile" in data_lines
assert byte_size(pack_data) > 0
# Verify the pack is parseable and contains expected object types
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) >= 3
types = Enum.map(entries, & &1.type) |> MapSet.new()
assert :blob in types
assert :tree in types
assert :commit in types
end
end
describe "buffering split data" do
test "handles ls-refs command split across two feed calls" do
test "returns packfile for repo with multiple commits" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello", "init")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{shas, _last} = create_chain(repo, 5)
tip = List.last(shas)
{_advert, state} = UploadPackV2.init(repo)
client_data =
# Build a complete ls-refs command
full_cmd =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{tip}") <>
PktLine.encode("done") <>
IO.iodata_to_binary([
PktLine.encode("command=ls-refs"),
PktLine.delim(),
PktLine.encode("ref-prefix refs/heads/"),
PktLine.flush()
])
# Split at an arbitrary point mid-command
split_point = div(byte_size(full_cmd), 2)
<<part1::binary-size(split_point), part2::binary>> = full_cmd
{response, new_state} = UploadPackV2.feed(state, client_data)
# First feed: should buffer, return empty response
{response1, state1} = UploadPackV2.feed(state, part1)
assert response1 == <<>>
assert state1.phase == :command
assert state1.buffer == part1
assert new_state.phase == :done
pack_data = extract_sideband_pack(response)
assert pack_data != nil
# Second feed: should complete the command
{response2, state2} = UploadPackV2.feed(state1, part2)
assert state2.phase == :command
assert state2.buffer == <<>>
assert byte_size(response2) > 0
assert {:ok, entries} = Reader.parse(pack_data)
{:ok, packets, _} = PktLine.decode(response2)
data_lines = for {:data, d} <- packets, do: d
assert Enum.any?(data_lines, &String.contains?(&1, commit_sha))
# 5 commits + 5 trees + 5 blobs = 15 objects
assert length(entries) == 15
commit_count = Enum.count(entries, &(&1.type == :commit))
assert commit_count == 5
end
test "handles fetch command split across three feed calls" do
test "returns packfile for repo with many commits (30+)" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{shas, _last} = create_chain(repo, 30)
tip = List.last(shas)
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=fetch") <>
{commit_sha, _, _} = create_commit(repo, "content", "commit")
PktLine.delim() <>
PktLine.encode("want #{tip}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, new_state} = UploadPackV2.feed(state, client_data)
assert new_state.phase == :done
pack_data = extract_sideband_pack(response)
assert pack_data != nil
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 90
end
test "packfile response is parseable by git protocol" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "test\n", "test\n")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
client_data =
# Build a complete fetch command
full_cmd =
IO.iodata_to_binary([
PktLine.encode("command=fetch"),
PktLine.delim(),
PktLine.encode("want #{commit_sha}"),
PktLine.encode("done"),
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{commit_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
])
# Split into three parts
size = byte_size(full_cmd)
s1 = div(size, 3)
s2 = div(size * 2, 3)
<<p1::binary-size(s1), rest::binary>> = full_cmd
<<p2::binary-size(s2 - s1), p3::binary>> = rest
{response, _state} = UploadPackV2.feed(state, client_data)
# Parse the full response as pkt-lines
# For a clone (no haves), the expected format is:
# "packfile\n" pkt-line
# sideband data (band=1 prefix for pack data)
# Feed part 1
{r1, st1} = UploadPackV2.feed(state, p1)
assert r1 == <<>>
assert st1.phase == :command
# 0000 flush
{:ok, packets, rest} = PktLine.decode(response)
# Feed part 2
{r2, st2} = UploadPackV2.feed(st1, p2)
assert r2 == <<>>
assert st2.phase == :command
# First data packet should be "packfile"
first_data =
Enum.find(packets, fn
{:data, _} -> true
_ -> false
end)
# Feed part 3 — should complete
{r3, st3} = UploadPackV2.feed(st2, p3)
assert st3.phase == :done
assert byte_size(r3) > 0
assert {:data, packfile_line} = first_data
assert String.trim(packfile_line) == "packfile"
# After the packfile line, the remaining data is sideband-encoded
{:ok, packets, _} = PktLine.decode(r3)
data_lines = for {:data, d} <- packets, do: d
assert "packfile" in data_lines
# It should end with a flush
assert :flush in packets or rest == "" or String.ends_with?(response, "0000")
end
end
test "handles split at exact pkt-line boundary" do
describe "fetch command - incremental (with haves)" do
test "sends only new objects when client has previous commit" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "test", "init")
{c1_sha, _, _} = create_commit(repo, "v1\n", "first\n")
:ok = Ref.put(repo, "refs/heads/main", c1_sha, nil)
{c2_sha, _, _} = create_commit(repo, "v2\n", "second\n", [c1_sha])
:ok = Ref.put(repo, "refs/heads/main", c2_sha, c1_sha)
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{c2_sha}") <>
PktLine.encode("have #{c1_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, new_state} = UploadPackV2.feed(state, client_data)
assert new_state.phase == :done
# Should have acknowledgments section with ACK for c1
assert String.contains?(response, "acknowledgments")
assert String.contains?(response, "ACK #{c1_sha}")
assert String.contains?(response, "ready")
# Should have packfile section
assert String.contains?(response, "packfile")
pack_data = extract_sideband_pack(response)
assert pack_data != nil
assert {:ok, entries} = Reader.parse(pack_data)
# Should only have new objects (c2's commit + tree + blob = 3)
assert length(entries) == 3
end
test "returns NAK when no common ancestors" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{c1_sha, _, _} = create_commit(repo, "v1\n", "first\n")
:ok = Ref.put(repo, "refs/heads/main", c1_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
fake_have = String.duplicate("f", 40)
client_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{c1_sha}") <>
PktLine.encode("have #{fake_have}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, _state} = UploadPackV2.feed(state, client_data)
assert String.contains?(response, "acknowledgments")
assert String.contains?(response, "NAK")
end
end
describe "full HTTP-style flow (ls-refs then fetch)" do
test "simulates a complete git clone via protocol v2" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello world\n", "initial commit\n")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
# Step 1: init (capability advertisement)
{advert, state} = UploadPackV2.init(repo)
assert String.contains?(advert, "version 2")
# Step 2: ls-refs (client discovers refs)
ls_refs_data =
PktLine.encode("command=ls-refs") <>
PktLine.delim() <>
PktLine.encode("peel") <>
PktLine.encode("symrefs") <>
PktLine.encode("ref-prefix refs/heads/") <>
PktLine.encode("ref-prefix refs/tags/") <>
PktLine.flush()
{ls_refs_response, state} = UploadPackV2.feed(state, ls_refs_data)
assert String.contains?(ls_refs_response, commit_sha)
assert state.phase == :command
# Step 3: fetch (client requests objects)
fetch_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{commit_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
{fetch_response, state} = UploadPackV2.feed(state, fetch_data)
assert state.phase == :done
# Verify the pack data is valid
pack_data = extract_sideband_pack(fetch_response)
assert pack_data != nil
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) >= 3
end
test "simulates incremental fetch (pull)" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{c1_sha, _, _} = create_commit(repo, "v1\n", "first\n")
:ok = Ref.put(repo, "refs/heads/main", c1_sha, nil)
{c2_sha, _, _} = create_commit(repo, "v2\n", "second\n", [c1_sha])
:ok = Ref.put(repo, "refs/heads/main", c2_sha, c1_sha)
{c3_sha, _, _} = create_commit(repo, "v3\n", "third\n", [c2_sha])
:ok = Ref.put(repo, "refs/heads/main", c3_sha, c2_sha)
# Client already has c1, wants c3 (tip)
{_advert, state} = UploadPackV2.init(repo)
# Split exactly between the command line and the delim
fetch_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{c3_sha}") <>
PktLine.encode("have #{c1_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, _state} = UploadPackV2.feed(state, fetch_data)
command_line = PktLine.encode("command=ls-refs")
rest_of_cmd = IO.iodata_to_binary([PktLine.delim(), PktLine.flush()])
{r1, st1} = UploadPackV2.feed(state, command_line)
pack_data = extract_sideband_pack(response)
assert pack_data != nil
assert {:ok, entries} = Reader.parse(pack_data)
assert r1 == <<>>
assert st1.phase == :command
{r2, st2} = UploadPackV2.feed(st1, rest_of_cmd)
assert st2.phase == :command
assert byte_size(r2) > 0
# c2 + c3 each have commit + tree + blob = 6 objects
assert length(entries) == 6
end
end
describe "full v2 conversation" do
test "ls-refs followed by fetch" do
describe "error handling" do
test "returns flush for unknown command" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=invalid") <>
PktLine.delim() <>
PktLine.flush()
{commit_sha, _, _} = create_commit(repo, "hello world", "initial")
{response, new_state} = UploadPackV2.feed(state, client_data)
assert response == PktLine.flush()
assert new_state.phase == :done
end
test "returns flush for malformed data" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPackV2.init(repo)
{response, new_state} = UploadPackV2.feed(state, "garbage data")
assert response == PktLine.flush()
assert new_state.phase == :done
end
test "fetch with non-existent want SHA returns error gracefully" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
fake_sha = String.duplicate("a", 40)
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{fake_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, new_state} = UploadPackV2.feed(state, client_data)
assert new_state.phase == :done
# Should return something parseable (either an empty pack or a flush)
assert byte_size(response) > 0
end
end
describe "scale - large repos" do
test "handles 100 commits with multi-file trees" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
# Create 100 commits, each with 5 files (simulates a real project)
{_shas, tip} =
Enum.reduce(1..100, {[], nil}, fn i, {shas, parent} ->
parents = if parent, do: [parent], else: []
# Create 5 blobs per commit
blobs =
Enum.map(1..5, fn j ->
content = "file #{j} content at commit #{i}\n" <> String.duplicate("x", 100)
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
%{mode: "100644", name: "file_#{j}.txt", sha: blob_sha}
end)
tree = Tree.new(blobs)
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "Test <t@t.com> #{1_000_000_000 + i} +0000",
committer: "Test <t@t.com> #{1_000_000_000 + i} +0000",
message: "commit #{i}\n"
}
{:ok, sha} = Object.write(repo, commit)
if i == 1 do
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
else
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
end
{shas ++ [sha], sha}
end)
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{tip}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, new_state} = UploadPackV2.feed(state, client_data)
assert new_state.phase == :done
assert String.contains?(response, "packfile")
pack_data = extract_sideband_pack(response)
assert pack_data != nil
assert {:ok, entries} = Reader.parse(pack_data)
# 100 commits + 100 trees + 500 blobs = 700 objects
# (some blobs may be deduplicated if content repeats)
assert length(entries) > 200
assert Enum.count(entries, &(&1.type == :commit)) == 100
end
test "pack data starts with valid PACK header" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "test\n", "test\n")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
client_data =
# Step 1: ls-refs
ls_refs =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{commit_sha}") <>
PktLine.encode("done") <>
IO.iodata_to_binary([
PktLine.encode("command=ls-refs"),
PktLine.delim(),
PktLine.encode("ref-prefix refs/heads/"),
PktLine.flush()
])
{ls_response, state} = UploadPackV2.feed(state, ls_refs)
assert state.phase == :command
{:ok, ls_packets, _} = PktLine.decode(ls_response)
data_lines = for {:data, d} <- ls_packets, do: d
{response, _state} = UploadPackV2.feed(state, client_data)
pack_data = extract_sideband_pack(response)
assert pack_data != nil
# Git pack format starts with "PACK" magic bytes
assert <<"PACK", _version::32, _count::32, _rest::binary>> = pack_data
end
@tag :slow
test "handles 200 commits (production-scale)" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{_shas, tip} = create_chain(repo, 200)
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{tip}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, new_state} = UploadPackV2.feed(state, client_data)
assert new_state.phase == :done
assert String.contains?(response, "packfile")
pack_data = extract_sideband_pack(response)
assert pack_data != nil
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 600
end
end
describe "real git client validation" do
@tag :tmp_dir
test "response can be cloned by real git", %{tmp_dir: tmp_dir} do
# Use a memory repo — the git verify-pack validation works on the pack binary
repo = RepoHelper.memory_repo("git-clone-test")
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello from test\n", "initial commit\n")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
# Generate the v2 capability advertisement
{advert, state} = UploadPackV2.init(repo)
# Simulate ls-refs command
ls_refs_data =
PktLine.encode("command=ls-refs") <>
PktLine.delim() <>
PktLine.encode("peel") <>
PktLine.encode("symrefs") <>
PktLine.encode("ref-prefix refs/heads/") <>
PktLine.flush()
{ls_refs_response, state} = UploadPackV2.feed(state, ls_refs_data)
{:ok, packets, ""} = PktLine.decode(ls_refs_response)
data_lines = for {:data, d} <- packets, do: d
assert Enum.any?(data_lines, &String.contains?(&1, commit_sha))
# Step 2: fetch
fetch =
IO.iodata_to_binary([
PktLine.encode("command=fetch"),
PktLine.delim(),
PktLine.encode("want #{commit_sha}"),
PktLine.encode("done"),
# Simulate fetch command — new state (HTTP is stateless per-request)
{_advert2, state2} = UploadPackV2.init(repo)
fetch_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{commit_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
])
{fetch_response, state} = UploadPackV2.feed(state, fetch)
assert state.phase == :done
assert byte_size(fetch_response) > 0
{fetch_response, _state} = UploadPackV2.feed(state2, fetch_data)
# Write the pack data to a file and verify git can read it
# Verify packfile in response
{:ok, fetch_packets, _} = PktLine.decode(fetch_response)
data_lines = for {:data, d} <- fetch_packets, do: d
assert "packfile" in data_lines
pack_data = extract_sideband_pack(fetch_response)
assert pack_data != nil
pack_path = Path.join(tmp_dir, "test.pack")
File.write!(pack_path, pack_data)
# Verify git can parse the pack by indexing it
{output, exit_code} = System.cmd("git", ["index-pack", pack_path], stderr_to_stdout: true)
assert exit_code == 0, "git index-pack failed: #{output}"
# Now verify-pack should work with the generated idx
{output2, exit_code2} =
System.cmd("git", ["verify-pack", "-v", pack_path], stderr_to_stdout: true)
# Extract and verify pack data
pack_data = extract_pack_data(fetch_response)
{:ok, objects} = Reader.parse(pack_data)
assert exit_code2 == 0, "git verify-pack failed: #{output2}"
assert String.contains?(output2, commit_sha)
types = Enum.map(objects, fn obj -> obj.type end)
assert :commit in types
assert :tree in types
assert :blob in types
end
end
# Extract raw pack data from sideband-encoded response
defp extract_pack_data(response) do
{:ok, packets, _} = PktLine.decode(response)
# --- Helpers ---
# Extract pack data from v2 fetch response sideband encoding.
# The response structure is:
# [acknowledgments section (optional)]
# "packfile\n" pkt-line
# sideband data (band=1 = pack data, band=2 = progress)
# 0000 flush
defp extract_sideband_pack(response) do
# Find the "packfile" marker and extract sideband data after it
case find_packfile_section(response) do
# Find the packfile marker, then collect sideband-1 data
{_, after_packfile} =
nil -> nil
sideband_start -> extract_sideband_loop(sideband_start, <<>>)
end
end
Enum.split_while(packets, fn
{:data, "packfile"} -> false
_ -> true
end)
after_packfile
|> tl()
|> Enum.flat_map(fn
{:data, data} ->
case PktLine.decode_sideband(data) do
{:pack, pack} -> [pack]
_ -> []
defp find_packfile_section(data) do
# Scan pkt-lines until we find one containing "packfile"
find_packfile_loop(data)
end
defp find_packfile_loop(<<>>), do: nil
defp find_packfile_loop(<<"0000", _::binary>>), do: nil
defp find_packfile_loop(<<"0001", rest::binary>>), do: find_packfile_loop(rest)
defp find_packfile_loop(<<hex::binary-size(4), rest::binary>>) do
case Integer.parse(hex, 16) do
{len, ""} when len >= 5 ->
payload_len = len - 4
if byte_size(rest) >= payload_len do
<<payload::binary-size(payload_len), remaining::binary>> = rest
if String.contains?(payload, "packfile") do
remaining
else
find_packfile_loop(remaining)
end
else
nil
end
_ ->
nil
end
end
defp find_packfile_loop(_), do: nil
[]
defp extract_sideband_loop(<<>>, acc), do: if(acc == <<>>, do: nil, else: acc)
defp extract_sideband_loop(<<"0000", _::binary>>, acc) do
if acc == <<>>, do: nil, else: acc
end)
|> IO.iodata_to_binary()
end
defp extract_sideband_loop(<<hex::binary-size(4), rest::binary>>, acc) do
case Integer.parse(hex, 16) do
{len, ""} when len >= 5 ->
payload_len = len - 4
if byte_size(rest) >= payload_len do
<<payload::binary-size(payload_len), rest_pkt::binary>> = rest
case payload do
<<1, pack_chunk::binary>> ->
extract_sideband_loop(rest_pkt, acc <> pack_chunk)
_ ->
extract_sideband_loop(rest_pkt, acc)
end
else
if acc == <<>>, do: nil, else: acc
end
_ ->
if acc == <<>>, do: nil, else: acc
end
end
defp extract_sideband_loop(_, acc), do: if(acc == <<>>, do: nil, else: acc)
end