@@ -326,24 +326,30 @@
haves = extract_shas(args, "have ")
done? = Enum.any?(args, &(String.trim(&1) == "done"))
wait_for_done? = Enum.any?(args, &(String.trim(&1) == "wait-for-done"))
shallow_opts = parse_shallow_opts(args)
# Shallow fetches are a single round: the client includes `deepen`
# / `shallow` args expecting shallow-info + packfile in the direct
# response, not a multi-round negotiation loop.
send_packfile? = done? or (shallow_opts != nil and not wait_for_done?)
Logger.info(
"UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves, " <>
"done=#{done?}, wait-for-done=#{wait_for_done?}"
"done=#{done?}, wait-for-done=#{wait_for_done?}, " <>
"shallow=#{shallow_opts != nil}, send_packfile=#{send_packfile?}"
)
ack_section = build_acknowledgments(repo, haves, done?)
ack_section = build_acknowledgments(repo, haves, send_packfile?)
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}
# Normal clone / fetch with `done`, OR shallow/deepen request:
# send packfile immediately.
send_packfile? ->
{build_packfile_response(repo, wants, haves, ack_section, shallow_opts), :done}
# Multi-round negotiation: client hasn't committed yet. Emit the
# ACKs and wait for another fetch command on the same session.
@@ -352,20 +358,27 @@
end
end
defp build_packfile_response(repo, wants, haves, ack_section) do
case collect_objects(repo, wants, haves) do
{:ok, objects} ->
defp build_packfile_response(repo, wants, haves, ack_section, shallow_opts) do
case collect_objects_maybe_shallow(repo, wants, haves, shallow_opts) do
{:ok, %{objects: objects} = walk} ->
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")
shallow_info = build_shallow_info(walk)
packfile_header = PktLine.encode("packfile")
sideband_data =
PktLine.encode_sideband(1, pack_data)
|> IO.iodata_to_binary()
IO.iodata_to_binary([
ack_section,
shallow_info,
packfile_header,
sideband_data,
IO.iodata_to_binary([ack_section, packfile_header, sideband_data, PktLine.flush()])
PktLine.flush()
])
{:error, reason} ->
Logger.error(
@@ -377,18 +390,18 @@
end
end
defp build_acknowledgments(_repo, [], _done) do
# 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.
defp build_acknowledgments(_repo, [], _send_packfile?) do
# Client sent no haves at all — this is an 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, send_packfile?) do
# Client sent at least one have. Per protocol v2 the client enters
# its `process_acks` state and expects an `acknowledgments` section
# from us — even if none of the haves match. Omitting the section
# here triggers the client error
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
# `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'
@@ -411,10 +424,11 @@
header = PktLine.encode("acknowledgments")
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 ->
# We're about to send a packfile, so the acks section must end
# with `ready` (+ delim) so the client knows to read the
# following section. `ready` with zero ACKs is spec-valid
# (`*ack` is 0-or-more).
send_packfile? ->
IO.iodata_to_binary([header | acks] ++ [PktLine.encode("ready"), PktLine.delim()])
# Multi-round negotiation: client hasn't sent `done` yet. No matches
@@ -548,5 +562,278 @@
{acc, vis}
end
end
end
# -- Shallow Fetch --
# Parse any shallow-related arguments out of the fetch request.
# Returns a shallow-opts map, or nil when none of the shallow args
# are present (fetch proceeds with the normal full-reachability walk).
defp parse_shallow_opts(args) do
trimmed = Enum.map(args, &String.trim/1)
depth =
Enum.find_value(trimmed, fn
"deepen " <> n ->
case Integer.parse(n) do
{value, ""} when value > 0 -> value
_ -> nil
end
_ ->
nil
end)
since =
Enum.find_value(trimmed, fn
"deepen-since " <> t ->
case Integer.parse(t) do
{value, ""} -> value
_ -> nil
end
_ ->
nil
end)
not_refs =
trimmed
|> Enum.filter(&String.starts_with?(&1, "deepen-not "))
|> Enum.map(&String.trim_leading(&1, "deepen-not "))
relative? = "deepen-relative" in trimmed
client_shallow =
trimmed
|> Enum.filter(&String.starts_with?(&1, "shallow "))
|> Enum.map(&String.trim_leading(&1, "shallow "))
|> MapSet.new()
if depth == nil and since == nil and not_refs == [] and MapSet.size(client_shallow) == 0 do
nil
else
%{
depth: depth,
since: since,
not_refs: not_refs,
relative?: relative?,
client_shallow: client_shallow
}
end
end
# Route through the shallow walker when shallow args are present,
# otherwise fall through to the standard full-reachability walk.
defp collect_objects_maybe_shallow(repo, wants, haves, nil) do
case collect_objects(repo, wants, haves) do
{:ok, objects} -> {:ok, %{objects: objects, new_shallow: [], unshallow: []}}
err -> err
end
end
defp collect_objects_maybe_shallow(repo, wants, _haves, shallow_opts) do
walk_shallow(repo, wants, shallow_opts)
end
# Depth-/date-/exclusion-aware walk starting from `wants`. Returns
# `{:ok, %{objects, new_shallow, unshallow}}` — `objects` is the
# pack entry list; `new_shallow` lists commits whose parents were
# excluded (become client's shallow boundaries); `unshallow` lists
# client boundaries whose parents are now included (client can drop
# its prior shallow marker).
defp walk_shallow(repo, wants, opts) do
excluded = build_excluded_set(repo, opts.not_refs)
# `budget` is the number of additional commits this branch can
# still walk. In absolute mode budget decrements every step; in
# relative mode, encountering a client_shallow commit resets the
# budget at its parent (depth counted from the boundary).
initial_budget = opts.depth || :infinity
init_queue = Enum.map(wants, fn w -> {w, initial_budget} end)
state = %{
repo: repo,
opts: opts,
excluded: excluded,
visited: MapSet.new(),
walked_commits: [],
new_shallow: MapSet.new(),
unshallow: MapSet.new()
}
final = walk_shallow_loop(init_queue, state)
objects = collect_shallow_objects(repo, Enum.reverse(final.walked_commits))
{:ok,
%{
objects: objects,
new_shallow: MapSet.to_list(final.new_shallow),
unshallow: MapSet.to_list(final.unshallow)
}}
rescue
e -> {:error, Exception.message(e)}
end
defp walk_shallow_loop([], state), do: state
defp walk_shallow_loop([{sha, budget} | rest], state) do
cond do
MapSet.member?(state.visited, sha) ->
walk_shallow_loop(rest, state)
MapSet.member?(state.excluded, sha) ->
walk_shallow_loop(rest, state)
budget_exhausted?(budget) ->
walk_shallow_loop(rest, state)
true ->
visit_shallow_commit(sha, budget, rest, state)
end
end
defp visit_shallow_commit(sha, budget, rest, state) do
case ObjectResolver.read(state.repo, sha) do
{:ok, %Commit{} = commit} ->
state = record_walked_commit(state, sha, commit)
process_shallow_parents(sha, commit, budget, rest, state)
_ ->
walk_shallow_loop(rest, state)
end
end
defp record_walked_commit(state, sha, commit) do
%{
state
| visited: MapSet.put(state.visited, sha),
walked_commits: [{sha, commit} | state.walked_commits]
}
end
defp process_shallow_parents(_sha, %Commit{parents: []}, _budget, rest, state) do
# Root commit — never a shallow boundary (no parents to lose).
walk_shallow_loop(rest, state)
end
defp process_shallow_parents(sha, %Commit{parents: parents} = commit, budget, rest, state) do
since_ok? = state.opts.since == nil or commit_time(commit) >= state.opts.since
walk_parents? = parent_walk_allowed?(budget) and since_ok?
cond do
not walk_parents? ->
# Boundary commit: we stop here, parents are excluded from the pack.
walk_shallow_loop(rest, %{state | new_shallow: MapSet.put(state.new_shallow, sha)})
true ->
state = maybe_unshallow(state, sha)
parent_items = Enum.map(parents, fn p -> {p, next_budget(sha, budget, state.opts)} end)
walk_shallow_loop(rest ++ parent_items, state)
end
end
defp maybe_unshallow(state, sha) do
if MapSet.member?(state.opts.client_shallow, sha) do
%{state | unshallow: MapSet.put(state.unshallow, sha)}
else
state
end
end
defp budget_exhausted?(:infinity), do: false
defp budget_exhausted?(n), do: n <= 0
defp parent_walk_allowed?(:infinity), do: true
defp parent_walk_allowed?(n), do: n > 1
# In `deepen-relative` mode, hitting a commit that the client already
# has as a shallow boundary resets the depth budget — the N in
# `deepen N` counts from the boundary, not from the tip.
defp next_budget(sha, budget, %{relative?: true, depth: depth, client_shallow: cs})
when is_integer(depth) do
if MapSet.member?(cs, sha), do: depth, else: decrement_budget(budget)
end
defp next_budget(_sha, budget, _opts), do: decrement_budget(budget)
defp decrement_budget(:infinity), do: :infinity
defp decrement_budget(n), do: n - 1
# Walk ancestry from each deepen-not ref, building a set of commits
# that the walk must never descend into. Accepts refs OR SHAs.
defp build_excluded_set(_repo, []), do: MapSet.new()
defp build_excluded_set(repo, refs) do
Enum.reduce(refs, MapSet.new(), fn ref, acc ->
case resolve_to_sha(repo, ref) do
{:ok, sha} -> walk_ancestry(repo, sha, acc)
_ -> acc
end
end)
end
defp resolve_to_sha(repo, candidate) do
if sha?(candidate), do: {:ok, candidate}, else: Ref.resolve(repo, candidate)
end
defp walk_ancestry(repo, sha, visited) do
if MapSet.member?(visited, sha) do
visited
else
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{parents: parents}} ->
visited = MapSet.put(visited, sha)
Enum.reduce(parents, visited, &walk_ancestry(repo, &1, &2))
_ ->
visited
end
end
end
# Extract the committer timestamp from a commit's committer line.
# Format: "<name> <<email>> <unix_ts> <tz>".
defp commit_time(%Commit{committer: committer}) do
case Regex.run(~r/(\d+)\s+[-+]\d{4}\s*$/, committer) do
[_, ts] -> String.to_integer(ts)
_ -> 0
end
end
# Build pack entries for all commits surfaced by the shallow walk:
# each commit itself, its root tree, and all reachable sub-trees
# and blobs. Parents are NOT followed — the shallow walk already
# decided which commits are in scope.
defp collect_shallow_objects(repo, commits) do
{objects, _visited} =
Enum.reduce(commits, {[], MapSet.new()}, fn {sha, commit}, {acc, visited} ->
if MapSet.member?(visited, sha) do
{acc, visited}
else
visited = MapSet.put(visited, sha)
{tree_objs, visited} = collect_tree_objects(repo, commit.tree, visited, 0)
commit_entry = {:commit, Object.encode_content_only(commit), sha}
{[commit_entry | tree_objs ++ acc], visited}
end
end)
Enum.reverse(objects)
end
# Build the shallow-info section per Documentation/technical/protocol-v2.txt:
#
# shallow-info = PKT-LINE("shallow-info" LF)
# *PKT-LINE((shallow | unshallow) LF)
# delim-pkt
#
# Omitted entirely when there's nothing to report.
defp build_shallow_info(%{new_shallow: [], unshallow: []}), do: <<>>
defp build_shallow_info(%{new_shallow: new_shallow, unshallow: unshallow}) do
header = PktLine.encode("shallow-info")
shallow_lines = Enum.map(new_shallow, fn sha -> PktLine.encode("shallow #{sha}") end)
unshallow_lines = Enum.map(unshallow, fn sha -> PktLine.encode("unshallow #{sha}") end)
IO.iodata_to_binary([header | shallow_lines ++ unshallow_lines] ++ [PktLine.delim()])
end
end