@@ -1,0 +1,376 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.UploadPackV2WalkerPropertyTest do
@moduledoc """
Property test for the v2 fetch walker (Anvil #215 / REQ-GIT-076).
Builds a random commit graph (with random subtree depth, randomly
injected gitlink entries, and a fraction of merge commits) and checks
one invariant: the SHA set produced by `UploadPackV2.feed/2` for a
given want-list must equal the SHA set reachable from those wants by
a plain BFS over the object graph.
Catches the entire class of walker bugs we have hit in production,
including the May 2026 gitlink-reachability regression (Anvil #214)
where a `mode: \"160000\"` tree entry whose SHA happened to match a
real commit caused the walker to silently prune the commit's entire
ancestor subgraph.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias ExGitObjectstore.{Object, Pack.Reader, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree, Tag}
alias ExGitObjectstore.Protocol.{PktLine, UploadPackV2}
alias ExGitObjectstore.Test.RepoHelper
@moduletag :property
describe "v2 fetch walker reachability invariant" do
@describetag requirements: ["REQ-GIT-076"]
property "pack response contains exactly the BFS-reachable object SHAs" do
check all(
spec <- graph_spec(),
max_runs: 80
) do
repo = RepoHelper.memory_repo("walker-prop-#{System.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
%{tip_shas: tip_shas} = build_graph(repo, spec)
# Walker output (what gets sent to the client in the pack body).
walker_shas = walker_pack_shas(repo, tip_shas)
# Ground truth: every SHA reachable from `tip_shas` by walking
# commit parents, tree subtrees, blob references, and tag
# targets — using whatever the storage layer actually serves.
bfs_shas = bfs_reachable(repo, tip_shas)
only_in_walker = MapSet.difference(walker_shas, bfs_shas)
only_in_bfs = MapSet.difference(bfs_shas, walker_shas)
assert MapSet.size(only_in_walker) == 0,
"walker emitted SHAs that aren't reachable: #{inspect(MapSet.to_list(only_in_walker))}\nspec: #{inspect(spec)}"
assert MapSet.size(only_in_bfs) == 0,
"walker missed reachable SHAs: #{inspect(MapSet.to_list(only_in_bfs))}\nspec: #{inspect(spec)}"
end
end
# REQ-GIT-081 / S7: partial-clone audit. The walker has special-case
# plumbing (`early_skip_blobs?/1` + `apply_filter/3` + `:skip_blobs`
# walk_opt) for `--filter=blob:none`. This property asserts that
# plumbing holds end-to-end: the wire pack must contain zero blobs
# when the client sends `filter blob:none`, and the reachable set
# minus blobs equals the wire set.
property "filter blob:none excludes every blob from the pack body" do
check all(
spec <- graph_spec(),
max_runs: 40
) do
repo = RepoHelper.memory_repo("filter-prop-#{System.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
%{tip_shas: tip_shas} = build_graph(repo, spec)
walker_shas = walker_pack_shas(repo, tip_shas, filter: "blob:none")
walker_blob_shas = blob_shas(repo, walker_shas)
assert MapSet.size(walker_blob_shas) == 0,
"filter blob:none should exclude every blob but #{MapSet.size(walker_blob_shas)} blobs leaked: #{inspect(MapSet.to_list(walker_blob_shas) |> Enum.take(5))}\nspec: #{inspect(spec)}"
# Non-blob reachable set must equal the wire set — we filtered
# blobs, not anything else.
bfs_non_blob = bfs_reachable(repo, tip_shas) |> MapSet.reject(&blob?(repo, &1))
assert MapSet.equal?(walker_shas, bfs_non_blob),
"walker drift on filter blob:none. only-walker=#{inspect(MapSet.difference(walker_shas, bfs_non_blob) |> MapSet.to_list())} only-bfs=#{inspect(MapSet.difference(bfs_non_blob, walker_shas) |> MapSet.to_list())}\nspec: #{inspect(spec)}"
end
end
end
# ── Graph spec generator ───────────────────────────────────────────
# A graph spec describes the shape of a small commit graph we'll
# build before running the walker on it. We deliberately mix:
# - linear chains (typical)
# - merge commits (multiple parents)
# - gitlink tree entries (the prod regression case)
#
# Keeping the parameter ranges small keeps each property iteration
# fast (< 50ms) while still exercising the structural shapes that
# matter for the walker.
defp graph_spec do
gen all(
chain_count <- integer(1..3),
chain_lengths <- list_of(integer(1..6), length: chain_count),
branches <- integer(1..min(3, chain_count)),
inject_gitlink? <- boolean(),
gitlink_target <- one_of([constant(:self), constant(:root)]),
inject_merge? <- boolean()
) do
%{
chain_count: chain_count,
chain_lengths: chain_lengths,
branches: branches,
inject_gitlink?: inject_gitlink?,
gitlink_target: gitlink_target,
inject_merge?: inject_merge?
}
end
end
# ── Repo construction ──────────────────────────────────────────────
defp build_graph(repo, spec) do
# Build N independent chains of commits, then optionally merge them.
chains =
Enum.map(0..(spec.chain_count - 1), fn idx ->
build_chain(repo, "chain#{idx}", Enum.at(spec.chain_lengths, idx))
end)
tips =
chains
|> Enum.take(spec.branches)
|> Enum.map(&List.last/1)
tips =
if spec.inject_merge? and length(tips) >= 2 do
[merge_tip(repo, tips) | tl(tips)]
else
tips
end
tips =
if spec.inject_gitlink? do
target =
case spec.gitlink_target do
:root -> hd(hd(chains))
:self -> hd(tips)
end
[add_gitlink_commit(repo, hd(tips), target) | tl(tips)]
else
tips
end
Enum.with_index(tips, fn sha, i ->
:ok = Ref.put(repo, "refs/heads/tip#{i}", sha, nil)
end)
%{tip_shas: tips}
end
defp build_chain(repo, label, length) do
Enum.reduce(1..length, [], fn i, acc ->
parents = if acc == [], do: [], else: [List.last(acc)]
sha = commit_with_file(repo, "#{label}/f#{i}", "content #{label} #{i}\n", parents)
acc ++ [sha]
end)
end
defp commit_with_file(repo, path, content, parents) do
{dir, name} =
case String.split(path, "/", parts: 2) do
[d, n] -> {d, n}
[n] -> {nil, n}
end
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
tree_entries =
if dir do
inner = Tree.new([%{mode: "100644", name: name, sha: blob_sha}])
{:ok, inner_sha} = Object.write(repo, inner)
[%{mode: "40000", name: dir, sha: inner_sha}]
else
[%{mode: "100644", name: name, sha: blob_sha}]
end
tree = Tree.new(tree_entries)
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "T <t@t> 1000000000 +0000",
committer: "T <t@t> 1000000000 +0000",
message: "#{path}\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
commit_sha
end
defp merge_tip(repo, tips) do
blob = Blob.from_content("merge\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "merge.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: tips,
author: "T <t@t> 1000000000 +0000",
committer: "T <t@t> 1000000000 +0000",
message: "merge\n"
}
{:ok, sha} = Object.write(repo, commit)
sha
end
# Build a commit whose tree contains a gitlink entry pointing at
# `link_target_sha`. This is the exact shape that caused 1043
# reachable objects to disappear from hephaestus's pack response
# before the fix in ex_git_objectstore PR #33.
defp add_gitlink_commit(repo, parent_sha, link_target_sha) do
blob = Blob.from_content("gitlink commit\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree =
Tree.new([
%{mode: "100644", name: "f.txt", sha: blob_sha},
%{mode: "160000", name: "submodule", sha: link_target_sha}
])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [parent_sha],
author: "T <t@t> 1000000000 +0000",
committer: "T <t@t> 1000000000 +0000",
message: "gitlink\n"
}
{:ok, sha} = Object.write(repo, commit)
sha
end
# ── Walker invocation ──────────────────────────────────────────────
defp walker_pack_shas(repo, tip_shas, opts \\ []) do
{_advert, state} = UploadPackV2.init(repo)
want_pkts =
tip_shas
|> Enum.map(&PktLine.encode("want " <> &1))
|> Enum.join("")
filter_pkt =
case Keyword.get(opts, :filter) do
nil -> ""
spec -> PktLine.encode("filter " <> spec)
end
fetch_req =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
want_pkts <>
filter_pkt <>
PktLine.encode("done") <>
PktLine.flush()
{response, _new_state} = UploadPackV2.feed(state, fetch_req)
pack = extract_pack(response)
case Reader.parse(pack) do
{:ok, entries} -> Enum.map(entries, & &1.sha) |> MapSet.new()
err -> flunk("Reader.parse failed: #{inspect(err)}\npack: #{byte_size(pack)} bytes")
end
end
defp blob?(repo, sha) do
case ExGitObjectstore.ObjectResolver.read(repo, sha) do
{:ok, %Blob{}} -> true
_ -> false
end
end
defp blob_shas(repo, sha_set) do
sha_set
|> Enum.filter(&blob?(repo, &1))
|> MapSet.new()
end
# ── Ground-truth BFS reachability ──────────────────────────────────
# Pure BFS over the object graph as the storage actually serves it.
# NOTE: we exclude submodule (mode 160000) entry SHAs — those name
# commits in another repo and are not expected to be in this repo's
# pack response, even when the SHA happens to also exist locally.
defp bfs_reachable(repo, starts) do
walk(repo, starts, MapSet.new())
end
defp walk(_repo, [], visited), do: visited
defp walk(repo, [sha | rest], visited) do
if MapSet.member?(visited, sha) do
walk(repo, rest, visited)
else
next =
case ExGitObjectstore.ObjectResolver.read(repo, sha) do
{:ok, %Commit{tree: t, parents: ps}} ->
[t | ps]
{:ok, %Tree{entries: es}} ->
for e <- es, e.mode != "160000", do: e.sha
{:ok, %Blob{}} ->
[]
{:ok, %Tag{object: t}} ->
[t]
_ ->
[]
end
walk(repo, next ++ rest, MapSet.put(visited, sha))
end
end
# ── Helpers ────────────────────────────────────────────────────────
# Strip v2 protocol framing and concatenate sideband-1 payloads to
# recover the raw pack bytes from a fetch response. Returns `<<>>`
# when the response has no packfile section (e.g. the walker
# returned an error and only a flush was sent).
defp extract_pack(response) do
{:ok, packets, _rest} = PktLine.decode(response)
# PktLine.decode strips the trailing LF from data packets, so the
# "packfile" marker arrives without its newline.
case Enum.drop_while(packets, fn
{:data, "packfile"} -> false
_ -> true
end) do
[] ->
<<>>
[_packfile_marker | body] ->
body
|> Enum.flat_map(fn
{:data, <<1, chunk::binary>>} -> [chunk]
_ -> []
end)
|> IO.iodata_to_binary()
end
end
end