ref:624b9d54e5ee9bd2ad647e8bb1c431195046729b

fix: UploadPackV2 emits `ready` (or omits acks) when client sends `done`

`git fetch` against a repo served by `UploadPackV2` failed with fatal: expected no other sections to be sent after no 'ready' when the client's local history was unrelated to what's on the server. The server emitted `acknowledgments\n NAK\n 0001 packfile\n ...`, which violates the protocol v2 grammar — real git clients reject any acknowledgments section that doesn't end with `ready` when a packfile section follows. Parse `done` from the fetch args and build the acks section accordingly: - `done` + no matching haves -> omit the acks section - `done` + some ACKs -> emit `ACK ... / ready / delim` - no `done` (multi-round negotiation) -> emit `NAK / flush` or `ACK ... / flush` Adds two test layers: - state-machine unit tests that lock in the protocol invariant - a real-`git`-client interop test that spins up an in-process `git://` TCP daemon and runs `git clone` / `git fetch` against UploadPackV2 (catches framing bugs pure-Elixir tests miss) Closes #27 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHA: 624b9d54e5ee9bd2ad647e8bb1c431195046729b
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-18 22:21
Parents: 2b54c14
3 files changed +441 -17
Type
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +36 −14
@@ -225,11 +225,14 @@
defp handle_fetch(repo, args) do
wants = extract_shas(args, "want ")
haves = extract_shas(args, "have ")
done = Enum.any?(args, &(String.trim(&1) == "done"))
Logger.info(
"UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves, done=#{done}"
)
Logger.info("UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves")
# Build acknowledgments section when client sends haves
ack_section = build_acknowledgments(repo, haves, done)
ack_section = build_acknowledgments(repo, haves)
case collect_objects(repo, wants, haves) do
{:ok, objects} ->
@@ -254,13 +257,13 @@
end
end
defp build_acknowledgments(_repo, []) do
# No haves = initial clone, no acknowledgments section needed
defp build_acknowledgments(_repo, [], _done) do
# No haves = initial clone, no acknowledgments section needed.
<<>>
end
defp build_acknowledgments(repo, haves, done) do
# Check which haves we have in common.
defp build_acknowledgments(repo, haves) do
# Check which haves we have in common
acks =
haves
|> Enum.filter(fn sha ->
@@ -271,16 +274,35 @@
end)
|> Enum.map(fn sha -> PktLine.encode("ACK #{sha}") end)
header = PktLine.encode("acknowledgments")
# Per protocol v2 (Documentation/technical/protocol-v2.txt):
# acknowledgments = PKT-LINE("acknowledgments" LF) (nak | *ack) [ready]
# and: if the client sent `done` the server MUST be "ready" — either by
# emitting a `ready` line at the end of the acks section, or by omitting
# the section entirely. Sending `NAK` followed by a packfile is a
# protocol violation; real git clients reject it with
# fatal: expected no other sections to be sent after no 'ready'
cond do
# Client sent `done` but nothing matched — omit the acks section.
# A packfile will follow unconditionally.
done and acks == [] ->
<<>>
# Client sent `done` — end the acks section with `ready` so the
# following packfile section is expected by the client.
done ->
header = PktLine.encode("acknowledgments")
IO.iodata_to_binary([header | acks] ++ [PktLine.encode("ready"), PktLine.delim()])
# Multi-round negotiation: client hasn't sent `done` yet.
# No matches found — tell the client to send more haves.
acks == [] ->
header = PktLine.encode("acknowledgments")
ack_lines =
if acks == [] do
[PktLine.encode("NAK")]
else
acks ++ [PktLine.encode("ready")]
end
IO.iodata_to_binary([header, PktLine.encode("NAK"), PktLine.flush()])
true ->
IO.iodata_to_binary([header | ack_lines] ++ [PktLine.delim()])
header = PktLine.encode("acknowledgments")
IO.iodata_to_binary([header | acks] ++ [PktLine.flush()])
end
end
defp extract_shas(args, prefix) do