@@ -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