ref:b0352c4a236e6fdc329d4284d3113d469d582ccc

fix: UploadPackV2 always emits acks section when haves were sent

Follow-up to #27. The previous fix omitted the `acknowledgments` section when the client sent haves that didn't match and also sent `done`. Real git clients that sent any have enter `process_acks` and reject this with fatal: expected 'acknowledgments', received 'packfile' Rule: if the `haves` list is non-empty, we MUST emit the `acknowledgments` section. With `done`, it ends in `ready`; otherwise `NAK` / `ACK` + flush (multi-round). The prior integration test didn't catch this because the freshly-init'd git client sent zero haves, so the broken branch never fired. This commit forces haves via `--negotiation-tip=refs/heads/main` + `--depth=1` and adds an explicit `refute` on the regression error. The state-machine test is also tightened to require the acks section to be present (not just "if present, must end in ready"). Closes #28 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHA: b0352c4a236e6fdc329d4284d3113d469d582ccc
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-18 23:24
Parents: 624b9d5
3 files changed +74 -37
Type
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +23 −21
@@ -258,12 +258,26 @@
end
defp build_acknowledgments(_repo, [], _done) do
# No haves = initial clone, no acknowledgments section needed.
# Client sent no haves at all (initial clone). The acknowledgments
# section is optional per protocol v2 and the client does not expect
# one in that case, so omit it.
<<>>
end
defp build_acknowledgments(repo, haves, done) do
# Client sent at least one have. Per protocol v2 the client enters its
# `process_acks` state after sending haves and expects an
# Check which haves we have in common.
# `acknowledgments` section from us — even if none of the haves match.
# Omitting the section here causes the client to error with
# fatal: expected 'acknowledgments', received 'packfile'
# and emitting a section that ends with `NAK` before a packfile causes
# fatal: expected no other sections to be sent after no 'ready'
#
# Grammar (Documentation/technical/protocol-v2.txt):
# acknowledgments = PKT-LINE("acknowledgments" LF) (nak | *ack) [ready]
# When `done` is in the request we must end with `ready` (packfile
# follows); otherwise we end with `NAK` / `ACK`s and a flush (client
# sends another round of haves).
acks =
haves
|> Enum.filter(fn sha ->
@@ -274,33 +288,21 @@
end)
|> Enum.map(fn sha -> PktLine.encode("ACK #{sha}") end)
# 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.
header = PktLine.encode("acknowledgments")
# 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.
cond do
# Client sent `done` — packfile follows, so end with `ready`.
# `ready` is valid with zero ACKs (spec allows `*ack [ready]` where
# `*ack` is zero or more).
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
# Multi-round negotiation: client hasn't sent `done` yet.
# No matches found — tell the client to send more haves.
# found — tell the client to send more haves.
acks == [] ->
header = PktLine.encode("acknowledgments")
IO.iodata_to_binary([header, PktLine.encode("NAK"), PktLine.flush()])
true ->
header = PktLine.encode("acknowledgments")
IO.iodata_to_binary([header | acks] ++ [PktLine.flush()])
end
end