ref:e378baf918185d16460d4a3f14170d5b7c9892f4

feat: wait-for-done + multi-round fetch negotiation

UploadPackV2's fetch handler unconditionally returned a packfile on every request and forced the state machine to :done, which broke both: * `git fetch --negotiate-only` — the client sends `wait-for-done` (and `done`) expecting ONLY an `acknowledgments` section, no packfile. Previously we'd still send a packfile; `--negotiate-only` then errored out with "server does not support wait-for-done". * Multi-round negotiation — git's default `consecutive` negotiator may send `want`s + `have`s without `done` on the first request, expecting the server to emit ACKs and wait. Previously the session terminated after the first response. Advertise the capability: fetch=shallow wait-for-done Response shape now depends on request flags: done, no wait-for-done → acks + packfile, session ends wait-for-done (± done) → acks only, session ends neither → acks only, stay in :command for next round `process_command/2` threads the next phase back from `handle_fetch/2` instead of hard-coding `:done`.
SHA: e378baf918185d16460d4a3f14170d5b7c9892f4
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-19 00:24
Parents: 9b57103
3 files changed +51 -13
Type
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +42 −8
@@ -102,9 +102,9 @@
{:fetch, args} ->
Logger.info("UploadPackV2: processing fetch command")
response = handle_fetch(state.repo, args)
{response, next_phase} = handle_fetch(state.repo, args)
Logger.info("UploadPackV2: fetch response #{byte_size(response)} bytes")
{response, %{state | phase: :done}}
{response, %{state | phase: next_phase}}
{:error, :no_command} ->
# No command= line found — could be a bare flush or partial data.
@@ -130,7 +130,7 @@
lines = [
PktLine.encode("version 2"),
PktLine.encode("ls-refs=unborn"),
PktLine.encode("fetch=shallow"),
PktLine.encode("fetch=shallow wait-for-done"),
PktLine.encode("server-option"),
PktLine.flush()
]
@@ -307,19 +307,52 @@
end
# -- fetch Command --
#
# Response shape depends on `done` and `wait-for-done`:
#
# `done` set, no `wait-for-done` → acks + packfile, session ends
# `wait-for-done` set (± `done`) → acks only, session ends after
# the caller closes (client
# explicitly asked not to
# receive a packfile — this is
# what `git fetch
# --negotiate-only` uses)
# neither set → acks only, stay in :command
# for the next round of
# negotiation
defp handle_fetch(repo, args) do
wants = extract_shas(args, "want ")
haves = extract_shas(args, "have ")
done = Enum.any?(args, &(String.trim(&1) == "done"))
done? = Enum.any?(args, &(String.trim(&1) == "done"))
wait_for_done? = Enum.any?(args, &(String.trim(&1) == "wait-for-done"))
Logger.info(
"UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves, done=#{done}"
"UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves, " <>
"done=#{done?}, wait-for-done=#{wait_for_done?}"
)
ack_section = build_acknowledgments(repo, haves, done?)
cond do
# `--negotiate-only` flow: client wants the ACKs and nothing else.
# Emit only the acks section; do not build a packfile. Session is
# complete — the client closes the connection.
wait_for_done? ->
{ack_section, :done}
# Regular clone / fetch with `done`: send packfile immediately.
done? ->
{build_packfile_response(repo, wants, haves, ack_section), :done}
# Build acknowledgments section when client sends haves
ack_section = build_acknowledgments(repo, haves, done)
# Multi-round negotiation: client hasn't committed yet. Emit the
# ACKs and wait for another fetch command on the same session.
true ->
{ack_section, :command}
end
end
defp build_packfile_response(repo, wants, haves, ack_section) do
case collect_objects(repo, wants, haves) do
{:ok, objects} ->
Logger.info("UploadPackV2: collected #{length(objects)} objects, generating pack")
@@ -336,7 +369,8 @@
{:error, reason} ->
Logger.error(
"UploadPackV2: collect_objects failed for #{length(wants)} wants, #{length(haves)} haves: #{inspect(reason)}"
"UploadPackV2: collect_objects failed for #{length(wants)} wants, " <>
"#{length(haves)} haves: #{inspect(reason)}"
)
PktLine.flush()