@@ -35,7 +35,7 @@
alias ExGitObjectstore.{ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Pack.{Filter, Writer}
alias ExGitObjectstore.Protocol.PktLine
@max_tree_depth 64
@@ -130,7 +130,8 @@
lines = [
PktLine.encode("version 2"),
PktLine.encode("ls-refs=unborn"),
PktLine.encode("fetch=shallow wait-for-done filter"),
PktLine.encode("fetch=shallow wait-for-done"),
PktLine.encode("server-option"),
PktLine.encode("object-format=sha1"),
PktLine.flush()
]
@@ -327,6 +328,7 @@
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)
filter_spec = parse_filter_spec(args)
# Shallow fetches are a single round: the client includes `deepen`
# / `shallow` args expecting shallow-info + packfile in the direct
@@ -336,7 +338,8 @@
Logger.info(
"UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves, " <>
"done=#{done?}, wait-for-done=#{wait_for_done?}, " <>
"shallow=#{shallow_opts != nil}, send_packfile=#{send_packfile?}"
"shallow=#{shallow_opts != nil}, filter=#{inspect(filter_spec)}, " <>
"send_packfile=#{send_packfile?}"
)
ack_section = build_acknowledgments(repo, haves, send_packfile?)
@@ -349,7 +352,8 @@
# 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}
{build_packfile_response(repo, wants, haves, ack_section, shallow_opts, filter_spec),
:done}
# Multi-round negotiation: client hasn't committed yet. Emit the
# ACKs and wait for another fetch command on the same session.
@@ -358,8 +362,8 @@
end
end
defp build_packfile_response(repo, wants, haves, ack_section, shallow_opts, filter_spec) do
case collect_objects_maybe_shallow(repo, wants, haves, shallow_opts, filter_spec) do
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)
@@ -624,15 +628,126 @@
# Route through the shallow walker when shallow args are present,
# otherwise fall through to the standard full-reachability walk.
# Objects are then passed through the filter (if any).
defp collect_objects_maybe_shallow(repo, wants, haves, shallow_opts, filter_spec) do
result =
case shallow_opts do
nil -> collect_objects(repo, wants, haves) |> wrap_full()
_ -> walk_shallow(repo, wants, shallow_opts)
end
with {:ok, walk} <- result do
{:ok, %{walk | objects: apply_filter(walk.objects, filter_spec, repo)}}
end
end
defp wrap_full({:ok, objects}), do: {:ok, %{objects: objects, new_shallow: [], unshallow: []}}
defp wrap_full(err), do: err
defp apply_filter(objects, nil, _repo), do: objects
defp apply_filter(objects, spec, repo) do
# Tree depth and blob path are needed by some filter specs
# (`tree:<n>`, `sparse:oid=…`). Compute both up-front by walking
# each commit's root tree; the maps are shared across all objects.
meta = compute_tree_metadata(repo, objects)
Enum.filter(objects, fn entry ->
Filter.include?(spec, filter_ctx_for(entry, meta), repo)
end)
end
defp filter_ctx_for({:blob, content, sha}, meta) do
%{
type: :blob,
size: byte_size(content),
tree_depth: Map.get(meta.depths, sha, :not_tree),
path: Map.get(meta.paths, sha)
}
end
defp filter_ctx_for({:tree, _data, sha}, meta) do
%{
type: :tree,
size: 0,
tree_depth: Map.get(meta.depths, sha, 0),
path: nil
}
end
defp filter_ctx_for({:commit, _data, _sha}, _meta) do
%{type: :commit, size: 0, tree_depth: :not_tree, path: nil}
end
defp filter_ctx_for({:tag, _data, _sha}, _meta) do
%{type: :tag, size: 0, tree_depth: :not_tree, path: nil}
end
# Walk each commit's tree, recording the minimum depth from root-tree
# for every tree and blob SHA, plus the first path we see each blob
# at. The depth for root trees is 0, their direct entries are 1, and
# so on — matching git's `tree:<depth>` filter semantics.
defp compute_tree_metadata(repo, objects) do
commit_shas = for {:commit, _, sha} <- objects, do: sha
Enum.reduce(commit_shas, %{depths: %{}, paths: %{}}, fn commit_sha, acc ->
case ObjectResolver.read(repo, commit_sha) do
{:ok, %Commit{tree: tree_sha}} -> walk_tree_metadata(repo, tree_sha, 0, "", acc)
_ -> acc
end
end)
end
defp walk_tree_metadata(repo, sha, depth, path, acc) do
existing = Map.get(acc.depths, sha, :infinity)
cond do
depth >= existing -> acc
true -> descend_tree_metadata(repo, sha, depth, path, put_depth(acc, sha, depth))
end
end
defp descend_tree_metadata(repo, sha, depth, path, acc) do
case ObjectResolver.read(repo, sha) do
{:ok, %Tree{entries: entries}} ->
Enum.reduce(entries, acc, fn entry, inner ->
child_path = if path == "", do: entry.name, else: path <> "/" <> entry.name
walk_tree_metadata(repo, entry.sha, depth + 1, child_path, inner)
end)
{:ok, %Blob{}} ->
put_path(acc, sha, path)
defp collect_objects_maybe_shallow(repo, wants, haves, nil) do
case collect_objects(repo, wants, haves) do
_ ->
acc
{:ok, objects} -> {:ok, %{objects: objects, new_shallow: [], unshallow: []}}
err -> err
end
end
defp put_depth(acc, sha, depth), do: %{acc | depths: Map.put(acc.depths, sha, depth)}
defp put_path(acc, sha, path), do: %{acc | paths: Map.put_new(acc.paths, sha, path)}
defp collect_objects_maybe_shallow(repo, wants, _haves, shallow_opts) do
walk_shallow(repo, wants, shallow_opts)
# Look up `filter <spec>` in the fetch args. Returns a parsed spec
# or nil (when the client didn't request filtering).
defp parse_filter_spec(args) do
args
|> Enum.map(&String.trim/1)
|> Enum.find_value(fn
"filter " <> spec ->
case Filter.parse(spec) do
{:ok, parsed} ->
parsed
{:error, reason} ->
Logger.warning(
"UploadPackV2: ignoring unparseable filter #{inspect(spec)}: #{inspect(reason)}"
)
nil
end
_ ->
nil
end)
end
# Depth-/date-/exclusion-aware walk starting from `wants`. Returns