ref:b77b43f6e20a9d8af0a8de68008f754f214ae75a

test: fill coverage gaps (filter, shallow, atomic) + fix deepen walker

Five coverage gaps flagged by grading are now closed, and the shallow walker got a correctness fix surfaced by the new tests. Added: - test/ex_git_objectstore/pack/filter_test.exs — 30 unit tests for Filter.parse/1 (every spec form + rejections) and include?/3 (every ctx shape per spec). - capabilities_test.exs gains: * --filter=object:type=commit yields a commits-only pack * --filter=combine:blob:none+tree:1 composes sub-filters * --filter=sparse:oid=<oid> matches blob paths against a sparse-checkout spec blob - receive_pack_git_client_test.exs gains: * atomic rejects the batch when one command has a stale old_sha (validation-phase rollback) * mid-commit storage failure rolls back every applied ref (uses new FailingStorage test backend) - test/support/failing_storage.ex — a Storage behaviour wrapper around Memory that can be armed with `fail_put_ref_once/2` to inject a one-shot put_ref failure. Used by the new atomic mid-commit-failure test. - capabilities_test.exs gains two deepen sub-protocol tests: * --shallow-since=<date> (deepen-since) * --shallow-exclude=<ref> (deepen-not) Fixed while writing those tests: Shallow walker was including commits that failed `deepen-since` or were in the `deepen-not` exclusion set — because the since/exclude check happened AFTER the commit was already recorded. Moved the check to parent-enqueue time: when examining a commit's parents, any parent that's excluded or pre-cutoff is pruned; if no walkable parent survives, the current commit correctly becomes a new shallow boundary. 793 tests, 0 failures.
SHA: b77b43f6e20a9d8af0a8de68008f754f214ae75a
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-19 01:17
Parents: 45d9667
5 files changed +742 -12
Type
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +30 −11
@@ -925,17 +925,36 @@
walk_shallow_loop(rest, state)
end
defp process_shallow_parents(sha, %Commit{parents: parents}, budget, rest, state) do
# A parent is "walkable" only if it isn't excluded (deepen-not)
# AND its commit-time meets the since cutoff (deepen-since).
# Parents that fail either test are pruned BEFORE the walk
# descends, so the current commit correctly becomes a shallow
# boundary when no parent survives.
walkable = Enum.filter(parents, &parent_walkable?(state, &1))
depth_ok? = parent_walk_allowed?(budget)
cond do
walkable == [] or not depth_ok? ->
walk_shallow_loop(rest, %{state | new_shallow: MapSet.put(state.new_shallow, sha)})
true ->
state = maybe_unshallow(state, sha)
parent_budget = next_budget(sha, budget, state.opts)
next_queue = Enum.reduce(walkable, rest, fn p, q -> :queue.in({p, parent_budget}, q) end)
walk_shallow_loop(next_queue, state)
end
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?
defp parent_walkable?(state, sha) do
not MapSet.member?(state.excluded, sha) and parent_meets_since?(state, sha)
end
if walk_parents? do
state = maybe_unshallow(state, sha)
parent_budget = next_budget(sha, budget, state.opts)
next_queue = Enum.reduce(parents, rest, fn p, q -> :queue.in({p, parent_budget}, q) end)
walk_shallow_loop(next_queue, state)
else
# Boundary commit: we stop here, parents are excluded from the pack.
walk_shallow_loop(rest, %{state | new_shallow: MapSet.put(state.new_shallow, sha)})
defp parent_meets_since?(%{opts: %{since: nil}}, _sha), do: true
defp parent_meets_since?(%{repo: repo, opts: %{since: ts}}, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = c} -> commit_time(c) >= ts
_ -> false
end
end
test/ex_git_objectstore/integration/receive_pack_git_client_test.exs +101 −1
@@ -28,7 +28,7 @@
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.{GitDaemon, RepoHelper}
alias ExGitObjectstore.Test.{FailingStorage, GitDaemon, RepoHelper}
@moduletag :integration
@moduletag timeout: :timer.minutes(1)
@@ -232,6 +232,106 @@
"atomic rollback failed — `accepted` was applied despite batch failure"
assert {:error, _} = Ref.get(repo, "refs/heads/rejected")
after
stop.()
end
end
# Validation-phase rejection on a stale old_sha (non-fast-forward
# in an atomic batch). The hook_rejected test above covers the
# update-hook branch; this one covers the old_sha/CAS branch.
@tag :tmp_dir
test "atomic rejects the batch when one command has a stale old_sha",
%{tmp_dir: tmp_dir} do
{port, stop, repo} =
seeded_receive_pack("atomic-stale", [{"a.txt", "v1\n"}, {"a.txt", "v2\n"}])
try do
{upload_port, stop_upload} = GitDaemon.start_upload_pack(repo)
client = Path.join(tmp_dir, "client")
try do
GitDaemon.seed_client_clone("git://127.0.0.1:#{upload_port}/repo", client)
after
stop_upload.()
end
# Diverge the client's main: reset --hard to v1, then commit
# differently. Pushing to main (which is at v2 on server)
# without --force is a non-ff.
GitDaemon.git!(client, ["reset", "--hard", "HEAD~1"])
File.write!(Path.join(client, "a.txt"), "divergent\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "divergent"])
{:ok, main_before} = Ref.get(repo, "refs/heads/main")
{out, code} =
GitDaemon.git_at(client, [
"push",
"--atomic",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main",
"main:refs/heads/side"
])
refute code == 0, "atomic non-ff should fail:\n#{out}"
assert {:ok, ^main_before} = Ref.get(repo, "refs/heads/main"),
"atomic rollback failed — main moved despite stale old_sha in batch"
assert {:error, _} = Ref.get(repo, "refs/heads/side"),
"atomic rollback failed — side was created despite batch failure"
after
stop.()
end
end
# Mid-commit storage failure: validations all pass, but the
# second ref's `put_ref` fails. Atomic rollback must restore the
# first ref (already written) to its pre-flight snapshot. Uses
# `FailingStorage`, a test-only backend that injects a one-shot
# error on a specific ref's `put_ref` call.
@tag :tmp_dir
test "mid-commit storage failure rolls back every applied ref",
%{tmp_dir: tmp_dir} do
{:ok, storage_pid} = FailingStorage.start_link()
repo =
ExGitObjectstore.Repo.new("atomic-storage-fail",
storage: {FailingStorage, FailingStorage.config(storage_pid)}
)
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_receive_pack(repo)
try do
client = GitDaemon.init_client_dir(tmp_dir)
File.write!(Path.join(client, "a.txt"), "hi\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "first"])
# Arm FailingStorage: the first `put_ref` for `refs/heads/two`
# will return `{:error, :injected_failure}`. `refs/heads/one`
# writes normally, then the rollback should remove it again.
FailingStorage.fail_put_ref_once(storage_pid, "refs/heads/two")
{out, code} =
GitDaemon.git_at(client, [
"push",
"--atomic",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/one",
"main:refs/heads/two"
])
refute code == 0, "atomic push with injected storage failure should fail:\n#{out}"
assert {:error, _} = Ref.get(repo, "refs/heads/one"),
"atomic rollback failed — `one` still present after mid-batch storage failure"
assert {:error, _} = Ref.get(repo, "refs/heads/two")
after
stop.()
end
test/ex_git_objectstore/integration/upload_pack_v2_capabilities_test.exs +258 −0
@@ -233,6 +233,101 @@
stop.()
end
end
# `--shallow-since=<date>` on the wire sends `deepen-since <unix_ts>`.
# Construct commits with distinct committer dates and assert the
# clone only contains the recent ones.
@tag :tmp_dir
test "--shallow-since=<date> honours deepen-since walk", %{tmp_dir: tmp_dir} do
# Two old commits at 2020-01-01 and 2020-02-01, one recent at
# 2024-06-01. Cutoff = 2024-01-01.
old_ts1 = 1_577_836_800
old_ts2 = 1_580_515_200
new_ts = 1_717_200_000
cutoff = 1_704_067_200
repo = RepoHelper.memory_repo("since")
ExGitObjectstore.init(repo)
sha_a = commit_at(repo, "v1\n", nil, old_ts1)
sha_b = commit_at(repo, "v2\n", sha_a, old_ts2)
sha_c = commit_at(repo, "v3\n", sha_b, new_ts)
:ok = Ref.put(repo, "refs/heads/main", sha_a, nil)
:ok = Ref.put(repo, "refs/heads/main", sha_b, sha_a)
:ok = Ref.put(repo, "refs/heads/main", sha_c, sha_b)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "since-clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--shallow-since=#{DateTime.from_unix!(cutoff) |> DateTime.to_iso8601()}",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "--shallow-since clone failed:\n#{out}"
{log, _} = GitDaemon.git_at(dest, ["log", "--oneline"])
lines = String.split(String.trim(log), "\n")
# Only the recent commit (sha_c) should be in the shallow
# clone; sha_a and sha_b are before the cutoff.
assert length(lines) == 1,
"expected 1 commit (after cutoff), got #{length(lines)}:\n#{log}"
after
stop.()
end
end
# `--shallow-exclude=<ref>` on the wire sends `deepen-not <ref>`.
# History reachable from the excluded ref is not included in the
# clone.
@tag :tmp_dir
test "--shallow-exclude=<ref> honours deepen-not walk", %{tmp_dir: tmp_dir} do
# main has 5 commits; tag v1 points at commit #2. Asking for a
# clone excluding v1 means commits 1-2 are excluded — we get
# only commits 3, 4, 5.
repo = make_linear_repo("exclude", 5)
# Find commit #2 (the second one).
{:ok, tip} = Ref.get(repo, "refs/heads/main")
second_from_tip = walk_back(repo, tip, 3)
:ok = Ref.put(repo, "refs/tags/v1", second_from_tip, nil)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "exclude-clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--shallow-exclude=v1",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "--shallow-exclude clone failed:\n#{out}"
{log, _} = GitDaemon.git_at(dest, ["log", "--oneline"])
lines = String.split(String.trim(log), "\n")
# main had 5 commits; v1 excludes 2 → 3 remain.
assert length(lines) == 3,
"expected 3 commits (after excluding v1), got #{length(lines)}:\n#{log}"
after
stop.()
end
end
end
describe "partial clone (--filter)" do
@@ -293,6 +388,139 @@
end
end
# `--filter=object:type=<type>` is a less-common spec but ours
# parses and honours it. Requesting object:type=commit yields a
# pack that contains only commit objects; git considers this a
# partial clone and sets promisor=true.
@tag :tmp_dir
test "--filter=object:type=commit yields a commits-only pack", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("filter-obj-type", 3)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "commits-only")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--filter=object:type=commit",
"--no-local",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "object:type=commit clone failed:\n#{out}"
{cfg, _} = GitDaemon.git_at(dest, ["config", "--get", "remote.origin.promisor"])
assert String.trim(cfg) == "true"
after
stop.()
end
end
# `combine:blob:none+tree:1` applies both filters: no blobs, and
# only root trees + their direct entries (which, with blobs
# already excluded, means just the root tree per commit).
@tag :tmp_dir
test "--filter=combine:… composes sub-filters", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("filter-combine", 3)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "combined")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--filter=combine:blob:none+tree:1",
"--no-local",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "combine filter clone failed:\n#{out}"
{cfg, _} = GitDaemon.git_at(dest, ["config", "--get", "remote.origin.promisor"])
assert String.trim(cfg) == "true"
after
stop.()
end
end
# `sparse:oid=<oid>` reads the blob at `oid` as a sparse-checkout
# spec. Only blobs whose path matches a pattern in that spec are
# included in the pack.
@tag :tmp_dir
test "--filter=sparse:oid=<oid> includes only matching blobs",
%{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("filter-sparse")
ExGitObjectstore.init(repo)
# One commit with two files: src/app.ex and test/app_test.ex.
app_blob = Blob.from_content("module App; end\n")
{:ok, app_sha} = Object.write(repo, app_blob)
test_blob = Blob.from_content("module AppTest; end\n")
{:ok, test_sha} = Object.write(repo, test_blob)
src_tree = Tree.new([%{mode: "100644", name: "app.ex", sha: app_sha}])
{:ok, src_tree_sha} = Object.write(repo, src_tree)
test_tree = Tree.new([%{mode: "100644", name: "app_test.ex", sha: test_sha}])
{:ok, test_tree_sha} = Object.write(repo, test_tree)
root_tree =
Tree.new([
%{mode: "40000", name: "src", sha: src_tree_sha},
%{mode: "40000", name: "test", sha: test_tree_sha}
])
{:ok, root_tree_sha} = Object.write(repo, root_tree)
# Sparse spec blob: include only src/
sparse_spec = Blob.from_content("/src/\n")
{:ok, sparse_sha} = Object.write(repo, sparse_spec)
commit = %Commit{
tree: root_tree_sha,
parents: [],
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: "init\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "sparse-clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--filter=sparse:oid=#{sparse_sha}",
"--no-local",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "sparse filter clone failed:\n#{out}"
{cfg, _} = GitDaemon.git_at(dest, ["config", "--get", "remote.origin.promisor"])
assert String.trim(cfg) == "true"
after
stop.()
end
end
# Earlier versions silently dropped an unparseable filter spec and
# returned a full pack, which is a worse outcome than failing
# loudly: the client thinks it has a partial clone and configures
@@ -431,5 +659,35 @@
{:ok, sha} = Object.write(repo, commit)
sha
end
# Write a commit whose committer/author timestamp is a specific
# unix timestamp — lets `deepen-since` tests construct a history
# straddling a known cutoff.
defp commit_at(repo, content, parent, unix_ts) do
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: List.wrap(parent),
author: "T <t@t.com> #{unix_ts} +0000",
committer: "T <t@t.com> #{unix_ts} +0000",
message: "c\n"
}
{:ok, sha} = Object.write(repo, commit)
sha
end
# Walk `steps` commits back from `sha` and return that ancestor.
# steps=0 → sha itself; steps=1 → its parent; etc.
defp walk_back(_repo, sha, 0), do: sha
defp walk_back(repo, sha, steps) do
{:ok, %Commit{parents: [parent | _]}} = ExGitObjectstore.ObjectResolver.read(repo, sha)
walk_back(repo, parent, steps - 1)
end
end
test/ex_git_objectstore/pack/filter_test.exs +216 −0
@@ -1,0 +1,216 @@
# 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.Pack.FilterTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.Blob
alias ExGitObjectstore.Pack.Filter
alias ExGitObjectstore.Test.RepoHelper
# --- parse/1 ---
describe "parse/1" do
test "blob:none" do
assert {:ok, :blob_none} = Filter.parse("blob:none")
end
test "blob:limit with raw byte count" do
assert {:ok, {:blob_limit, 100}} = Filter.parse("blob:limit=100")
end
test "blob:limit with k suffix" do
assert {:ok, {:blob_limit, 2048}} = Filter.parse("blob:limit=2k")
end
test "blob:limit with m suffix" do
expected_mb = 5 * 1024 * 1024
assert {:ok, {:blob_limit, ^expected_mb}} = Filter.parse("blob:limit=5m")
end
test "blob:limit with g suffix" do
expected_gb = 1 * 1024 * 1024 * 1024
assert {:ok, {:blob_limit, ^expected_gb}} = Filter.parse("blob:limit=1g")
end
test "blob:limit with uppercase unit" do
assert {:ok, {:blob_limit, 2048}} = Filter.parse("blob:limit=2K")
end
test "blob:limit rejects bad unit" do
assert {:error, {:bad_size_unit, _}} = Filter.parse("blob:limit=2x")
end
test "blob:limit rejects non-numeric" do
assert {:error, {:bad_size, _}} = Filter.parse("blob:limit=huge")
end
test "tree:0" do
assert {:ok, {:tree_depth, 0}} = Filter.parse("tree:0")
end
test "tree:5" do
assert {:ok, {:tree_depth, 5}} = Filter.parse("tree:5")
end
test "tree rejects negative depth" do
assert {:error, {:bad_tree_depth, _}} = Filter.parse("tree:-1")
end
test "object:type=blob" do
assert {:ok, {:object_type, :blob}} = Filter.parse("object:type=blob")
end
test "object:type=tree" do
assert {:ok, {:object_type, :tree}} = Filter.parse("object:type=tree")
end
test "object:type=commit" do
assert {:ok, {:object_type, :commit}} = Filter.parse("object:type=commit")
end
test "object:type=tag" do
assert {:ok, {:object_type, :tag}} = Filter.parse("object:type=tag")
end
test "object:type rejects unknown type" do
assert {:error, {:bad_object_type, "widget"}} = Filter.parse("object:type=widget")
end
test "sparse:oid parses the oid" do
oid = String.duplicate("a", 40)
assert {:ok, {:sparse_oid, ^oid}} = Filter.parse("sparse:oid=#{oid}")
end
test "combine:blob:none+tree:2" do
assert {:ok, {:combine, specs}} = Filter.parse("combine:blob:none+tree:2")
assert :blob_none in specs
assert {:tree_depth, 2} in specs
end
test "combine propagates parse errors" do
assert {:error, _} = Filter.parse("combine:blob:none+garbage:spec")
end
test "unknown filter rejected" do
assert {:error, {:unknown_filter, "widget:none"}} = Filter.parse("widget:none")
end
test "whitespace around spec is tolerated" do
assert {:ok, :blob_none} = Filter.parse(" blob:none ")
end
end
# --- include?/3 ---
describe "include?/3" do
setup do
repo = RepoHelper.memory_repo("filter-include")
ExGitObjectstore.init(repo)
%{repo: repo}
end
test "blob:none excludes blobs, includes everything else", %{repo: repo} do
refute Filter.include?(:blob_none, blob_ctx(100, nil), repo)
assert Filter.include?(:blob_none, commit_ctx(), repo)
assert Filter.include?(:blob_none, tree_ctx(0), repo)
assert Filter.include?(:blob_none, tag_ctx(), repo)
end
test "blob:limit includes blobs at or under the limit", %{repo: repo} do
spec = {:blob_limit, 1024}
assert Filter.include?(spec, blob_ctx(100, nil), repo)
assert Filter.include?(spec, blob_ctx(1024, nil), repo)
refute Filter.include?(spec, blob_ctx(1025, nil), repo)
assert Filter.include?(spec, commit_ctx(), repo)
end
test "tree:0 includes only root trees (depth 0) and non-tree-hierarchy objects",
%{repo: repo} do
spec = {:tree_depth, 0}
# Root tree is depth 0 → excluded (rule: depth < N, N=0)
refute Filter.include?(spec, tree_ctx(0), repo)
refute Filter.include?(spec, tree_ctx(1), repo)
refute Filter.include?(spec, blob_ctx(10, "x"), repo)
# Commits and tags aren't in the tree hierarchy (tree_depth :not_tree)
assert Filter.include?(spec, commit_ctx(), repo)
assert Filter.include?(spec, tag_ctx(), repo)
end
test "tree:1 includes root tree (depth 0), excludes deeper", %{repo: repo} do
spec = {:tree_depth, 1}
assert Filter.include?(spec, tree_ctx(0), repo)
refute Filter.include?(spec, tree_ctx(1), repo)
refute Filter.include?(spec, tree_ctx(2), repo)
end
test "object:type=blob includes only blobs", %{repo: repo} do
spec = {:object_type, :blob}
assert Filter.include?(spec, blob_ctx(10, nil), repo)
refute Filter.include?(spec, commit_ctx(), repo)
refute Filter.include?(spec, tree_ctx(0), repo)
refute Filter.include?(spec, tag_ctx(), repo)
end
test "object:type=commit includes only commits", %{repo: repo} do
spec = {:object_type, :commit}
assert Filter.include?(spec, commit_ctx(), repo)
refute Filter.include?(spec, blob_ctx(10, nil), repo)
refute Filter.include?(spec, tree_ctx(0), repo)
end
test "sparse:oid matches blob paths against the spec blob", %{repo: repo} do
# Sparse spec blob: "/src/\n/README.md\n"
sparse_content = "/src/\n/README.md\n"
spec_blob = Blob.from_content(sparse_content)
{:ok, sparse_sha} = Object.write(repo, spec_blob)
spec = {:sparse_oid, sparse_sha}
# Matches (under src/)
assert Filter.include?(spec, blob_ctx(10, "src/app.ex"), repo)
assert Filter.include?(spec, blob_ctx(10, "README.md"), repo)
# Doesn't match
refute Filter.include?(spec, blob_ctx(10, "test/foo.ex"), repo)
# Non-blob always included (filter reshapes blobs only)
assert Filter.include?(spec, commit_ctx(), repo)
assert Filter.include?(spec, tree_ctx(3), repo)
end
test "combine: all sub-specs must accept", %{repo: repo} do
spec = {:combine, [:blob_none, {:object_type, :blob}]}
# Both specs agree to exclude blobs, so the combined result is
# "nothing passes" — the intersection is empty.
refute Filter.include?(spec, blob_ctx(10, nil), repo)
# Commits and trees: commit passes :blob_none but not :object_type=blob → excluded
refute Filter.include?(spec, commit_ctx(), repo)
end
test "combine: spec that accepts all types", %{repo: repo} do
spec = {:combine, [{:blob_limit, 1024}, {:tree_depth, 100}]}
assert Filter.include?(spec, blob_ctx(500, "x"), repo)
refute Filter.include?(spec, blob_ctx(2000, "x"), repo)
# Tree at depth 0 passes tree:100 (depth < 100) and tree_depth is :not_tree-irrelevant for blob_limit (includes all non-blobs)
assert Filter.include?(spec, tree_ctx(5), repo)
end
end
# --- helpers ---
defp blob_ctx(size, path), do: %{type: :blob, size: size, tree_depth: 1, path: path}
defp tree_ctx(depth), do: %{type: :tree, size: 0, tree_depth: depth, path: nil}
defp commit_ctx, do: %{type: :commit, size: 0, tree_depth: :not_tree, path: nil}
defp tag_ctx, do: %{type: :tag, size: 0, tree_depth: :not_tree, path: nil}
end
test/support/failing_storage.ex +137 −0
@@ -1,0 +1,137 @@
# 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.Test.FailingStorage do
@moduledoc """
Storage backend wrapper for tests. Delegates everything to a
`Memory` store but lets the test inject a synthetic failure on a
specific `put_ref` call — typically used to verify that atomic
ReceivePack correctly rolls back when a mid-batch ref write fails.
Usage:
{:ok, pid} = FailingStorage.start_link()
repo = Repo.new("r", storage: {FailingStorage, FailingStorage.config(pid)})
FailingStorage.fail_put_ref_once(pid, "refs/heads/boom")
# Any subsequent put_ref for "refs/heads/boom" returns
# `{:error, :injected_failure}`. Writes to other refs, and
# subsequent writes to "refs/heads/boom" after the first
# injected failure, behave normally.
"""
alias ExGitObjectstore.Storage.Memory
@behaviour ExGitObjectstore.Storage
# --- setup ---
@spec start_link() :: {:ok, pid()}
def start_link do
{:ok, mem_pid} = Memory.start_link()
Agent.start_link(fn -> %{mem_pid: mem_pid, fail_put_ref: nil} end)
end
@spec config(pid()) :: map()
def config(pid), do: %{pid: pid}
@doc """
Arm a one-shot failure: the next `put_ref/5` call whose ref name
matches `ref` returns `{:error, :injected_failure}`; the arm is
cleared after it fires.
"""
@spec fail_put_ref_once(pid(), String.t()) :: :ok
def fail_put_ref_once(pid, ref) do
Agent.update(pid, fn state -> %{state | fail_put_ref: ref} end)
end
# --- Storage callbacks ---
@impl true
def get_object(config, prefix, sha), do: Memory.get_object(mem_cfg(config), prefix, sha)
@impl true
def put_object(config, prefix, sha, data),
do: Memory.put_object(mem_cfg(config), prefix, sha, data)
@impl true
def object_exists?(config, prefix, sha), do: Memory.object_exists?(mem_cfg(config), prefix, sha)
@impl true
def list_objects(config, prefix), do: Memory.list_objects(mem_cfg(config), prefix)
@impl true
def list_packs(config, prefix), do: Memory.list_packs(mem_cfg(config), prefix)
@impl true
def get_pack(config, prefix, pack_sha), do: Memory.get_pack(mem_cfg(config), prefix, pack_sha)
@impl true
def get_pack_index(config, prefix, pack_sha),
do: Memory.get_pack_index(mem_cfg(config), prefix, pack_sha)
@impl true
def put_pack(config, prefix, pack_sha, pack_data, idx_data),
do: Memory.put_pack(mem_cfg(config), prefix, pack_sha, pack_data, idx_data)
@impl true
def stream_pack(config, prefix, pack_sha),
do: Memory.stream_pack(mem_cfg(config), prefix, pack_sha)
@impl true
def get_ref(config, prefix, ref), do: Memory.get_ref(mem_cfg(config), prefix, ref)
@impl true
def put_ref(%{pid: pid} = config, prefix, ref, new_sha, old_sha) do
case Agent.get_and_update(pid, fn
%{fail_put_ref: ^ref} = state -> {:fail, %{state | fail_put_ref: nil}}
state -> {:ok, state}
end) do
:fail -> {:error, :injected_failure}
:ok -> Memory.put_ref(mem_cfg(config), prefix, ref, new_sha, old_sha)
end
end
@impl true
def delete_ref(config, prefix, ref), do: Memory.delete_ref(mem_cfg(config), prefix, ref)
@impl true
def list_refs(config, prefix, ref_prefix),
do: Memory.list_refs(mem_cfg(config), prefix, ref_prefix)
@impl true
def get_head(config, prefix), do: Memory.get_head(mem_cfg(config), prefix)
@impl true
def put_head(config, prefix, target), do: Memory.put_head(mem_cfg(config), prefix, target)
@impl true
def get_blob(config, prefix, key), do: Memory.get_blob(mem_cfg(config), prefix, key)
@impl true
def put_blob(config, prefix, key, data), do: Memory.put_blob(mem_cfg(config), prefix, key, data)
@impl true
def delete_blob(config, prefix, key), do: Memory.delete_blob(mem_cfg(config), prefix, key)
@impl true
def blob_exists?(config, prefix, key), do: Memory.blob_exists?(mem_cfg(config), prefix, key)
# --- helpers ---
defp mem_cfg(%{pid: pid}) do
mem_pid = Agent.get(pid, & &1.mem_pid)
Memory.config(mem_pid)
end
end