@@ -1,0 +1,466 @@
# 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.Graph.QueriesTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Graph, Object}
alias ExGitObjectstore.Object.{Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
# --- helpers ---
defp init_repo do
repo = RepoHelper.memory_repo("q-#{:erlang.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
repo
end
defp empty_tree_sha(repo) do
{:ok, sha} = Object.write(repo, Tree.new([]))
sha
end
defp commit(repo, tree, parents, ts) do
ident = "A <a@a.com> #{ts} +0000"
{:ok, sha} =
Object.write(repo, %Commit{
tree: tree,
parents: parents,
author: ident,
committer: ident,
message: "c\n"
})
sha
end
defp graph_of(repo) do
{:ok, g} = Graph.build(repo)
g
end
# --- ancestor? ---
describe "ancestor?/3" do
test "a commit is its own ancestor" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
assert {:ok, true} = Graph.ancestor?(g, a, a)
end
test "parent is an ancestor of child" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
b = commit(repo, t, [a], 2)
:ok = ExGitObjectstore.create_branch(repo, "main", b)
g = graph_of(repo)
assert {:ok, true} = Graph.ancestor?(g, a, b)
assert {:ok, false} = Graph.ancestor?(g, b, a)
end
test "unrelated commits are not ancestors of each other" do
repo = init_repo()
t = empty_tree_sha(repo)
left = commit(repo, t, [], 1)
right = commit(repo, t, [], 2)
:ok = ExGitObjectstore.create_branch(repo, "l", left)
:ok = ExGitObjectstore.create_branch(repo, "r", right)
g = graph_of(repo)
assert {:ok, false} = Graph.ancestor?(g, left, right)
assert {:ok, false} = Graph.ancestor?(g, right, left)
end
test "across a merge, each parent is an ancestor of the merge commit" do
repo = init_repo()
t = empty_tree_sha(repo)
root = commit(repo, t, [], 1)
left = commit(repo, t, [root], 2)
right = commit(repo, t, [root], 3)
m = commit(repo, t, [left, right], 4)
:ok = ExGitObjectstore.create_branch(repo, "main", m)
g = graph_of(repo)
assert {:ok, true} = Graph.ancestor?(g, root, m)
assert {:ok, true} = Graph.ancestor?(g, left, m)
assert {:ok, true} = Graph.ancestor?(g, right, m)
# Sibling branches are not each other's ancestors.
assert {:ok, false} = Graph.ancestor?(g, left, right)
assert {:ok, false} = Graph.ancestor?(g, right, left)
end
test "missing commit returns :missing_commit" do
repo = init_repo()
g = graph_of(repo)
missing = String.duplicate("0", 40)
assert {:error, :missing_commit} = Graph.ancestor?(g, missing, missing)
end
end
# --- ahead_behind ---
describe "ahead_behind/3" do
test "identical commits → 0/0" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
assert {:ok, %{ahead: 0, behind: 0}} = Graph.ahead_behind(g, a, a)
end
test "linear chain: head is 2 ahead of base" do
repo = init_repo()
t = empty_tree_sha(repo)
base = commit(repo, t, [], 1)
mid = commit(repo, t, [base], 2)
head = commit(repo, t, [mid], 3)
:ok = ExGitObjectstore.create_branch(repo, "main", head)
g = graph_of(repo)
assert {:ok, %{ahead: 2, behind: 0}} = Graph.ahead_behind(g, base, head)
assert {:ok, %{ahead: 0, behind: 2}} = Graph.ahead_behind(g, head, base)
end
test "diverged: 1 ahead, 1 behind" do
repo = init_repo()
t = empty_tree_sha(repo)
root = commit(repo, t, [], 1)
base = commit(repo, t, [root], 2)
head = commit(repo, t, [root], 3)
g = graph_of(repo) |> then(fn _ -> nil end)
:ok = ExGitObjectstore.create_branch(repo, "base", base)
:ok = ExGitObjectstore.create_branch(repo, "head", head)
_ = g
g = graph_of(repo)
assert {:ok, %{ahead: 1, behind: 1}} = Graph.ahead_behind(g, base, head)
end
test "merge history: counts commits on each side of merge base" do
repo = init_repo()
t = empty_tree_sha(repo)
# f (head)
# /
# root - a - b - c (base)
# \
# d - e (f's other parent? no — f has one parent d)
# Shape: root → a → b → c, and root → d → e → f, branches at root.
root = commit(repo, t, [], 1)
a = commit(repo, t, [root], 2)
b = commit(repo, t, [a], 3)
c = commit(repo, t, [b], 4)
d = commit(repo, t, [root], 5)
e = commit(repo, t, [d], 6)
f = commit(repo, t, [e], 7)
:ok = ExGitObjectstore.create_branch(repo, "base", c)
:ok = ExGitObjectstore.create_branch(repo, "head", f)
g = graph_of(repo)
# merge base is root; ahead side (f..root) = {d, e, f} = 3
# behind side (c..root) = {a, b, c} = 3
assert {:ok, %{ahead: 3, behind: 3}} = Graph.ahead_behind(g, c, f)
end
test "disjoint histories: all-ahead, all-behind" do
repo = init_repo()
t = empty_tree_sha(repo)
a1 = commit(repo, t, [], 1)
a2 = commit(repo, t, [a1], 2)
b1 = commit(repo, t, [], 10)
b2 = commit(repo, t, [b1], 11)
:ok = ExGitObjectstore.create_branch(repo, "a", a2)
:ok = ExGitObjectstore.create_branch(repo, "b", b2)
g = graph_of(repo)
assert {:ok, %{ahead: 2, behind: 2}} = Graph.ahead_behind(g, a2, b2)
end
test "merge commit does not double-count shared ancestors" do
repo = init_repo()
t = empty_tree_sha(repo)
root = commit(repo, t, [], 1)
a = commit(repo, t, [root], 2)
b = commit(repo, t, [root], 3)
m = commit(repo, t, [a, b], 4)
extra = commit(repo, t, [m], 5)
:ok = ExGitObjectstore.create_branch(repo, "base", m)
:ok = ExGitObjectstore.create_branch(repo, "head", extra)
g = graph_of(repo)
assert {:ok, %{ahead: 1, behind: 0}} = Graph.ahead_behind(g, m, extra)
end
test "missing commit returns :missing_commit" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
missing = String.duplicate("0", 40)
assert {:error, :missing_commit} = Graph.ahead_behind(g, a, missing)
assert {:error, :missing_commit} = Graph.ahead_behind(g, missing, a)
end
end
# --- commits_between ---
describe "commits_between/3" do
test "empty when base == head" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
assert {:ok, []} = Graph.commits_between(g, a, a)
end
test "linear chain: returns commits between, newest-first" do
repo = init_repo()
t = empty_tree_sha(repo)
base = commit(repo, t, [], 1)
mid = commit(repo, t, [base], 2)
head = commit(repo, t, [mid], 3)
:ok = ExGitObjectstore.create_branch(repo, "main", head)
g = graph_of(repo)
assert {:ok, [^head, ^mid]} = Graph.commits_between(g, base, head)
end
test "excludes base's own ancestors" do
repo = init_repo()
t = empty_tree_sha(repo)
root = commit(repo, t, [], 1)
base = commit(repo, t, [root], 2)
head = commit(repo, t, [base], 3)
:ok = ExGitObjectstore.create_branch(repo, "main", head)
g = graph_of(repo)
# root is reachable from base, so it's excluded
assert {:ok, [^head]} = Graph.commits_between(g, base, head)
end
test "merge on head: includes both sides down to merge base" do
repo = init_repo()
t = empty_tree_sha(repo)
mb = commit(repo, t, [], 1)
base = mb
a = commit(repo, t, [mb], 2)
b = commit(repo, t, [mb], 3)
m = commit(repo, t, [a, b], 4)
:ok = ExGitObjectstore.create_branch(repo, "base", base)
:ok = ExGitObjectstore.create_branch(repo, "head", m)
g = graph_of(repo)
{:ok, commits} = Graph.commits_between(g, base, m)
# m, a, b are reachable from head but not from base; newest-first means m first.
assert hd(commits) == m
assert Enum.sort(commits) == Enum.sort([a, b, m])
end
test "missing commit returns :missing_commit" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
missing = String.duplicate("0", 40)
assert {:error, :missing_commit} = Graph.commits_between(g, a, missing)
end
end
# --- equivalence with the Walk module on random DAGs ---
#
# Walk provides merge_base/3 which we can use as a reference. For each
# random DAG we build, compare Graph.ancestor? against a brute-force
# reachability walk via cat_object, and compare ahead_behind / merge-base
# shape against the Walk module where feasible.
describe "equivalence on random DAGs" do
@iterations 10
test "ancestor? matches a brute-force reachability walker" do
ex_unit_seed = ExUnit.configuration()[:seed]
for i <- 1..@iterations do
:rand.seed(:exsss, {ex_unit_seed, i, 7})
repo = init_repo()
t = empty_tree_sha(repo)
{shas, _tips} = random_dag(repo, t, 15)
# Register all shas as branches so they're reachable.
for {sha, idx} <- Enum.with_index(shas),
do: :ok = ExGitObjectstore.create_branch(repo, "b-#{idx}", sha)
g = graph_of(repo)
for a <- shas, b <- shas do
{:ok, got} = Graph.ancestor?(g, a, b)
expected = brute_force_ancestor?(repo, a, b)
assert got == expected,
"iter=#{i}: Graph.ancestor?(#{String.slice(a, 0, 7)}, #{String.slice(b, 0, 7)}) = #{got}, expected #{expected}"
end
end
end
test "ahead_behind: counts match brute-force set difference" do
ex_unit_seed = ExUnit.configuration()[:seed]
for i <- 1..@iterations do
:rand.seed(:exsss, {ex_unit_seed, i, 11})
repo = init_repo()
t = empty_tree_sha(repo)
{shas, tips} = random_dag(repo, t, 20)
for {sha, idx} <- Enum.with_index(shas),
do: :ok = ExGitObjectstore.create_branch(repo, "b-#{idx}", sha)
g = graph_of(repo)
pairs =
for a <- Enum.take_random(tips, min(3, length(tips))),
b <- Enum.take_random(tips, min(3, length(tips))),
a != b,
do: {a, b}
for {base, head} <- pairs do
{:ok, %{ahead: ahead, behind: behind}} = Graph.ahead_behind(g, base, head)
base_anc = brute_force_ancestors(repo, base)
head_anc = brute_force_ancestors(repo, head)
expected_ahead = MapSet.size(MapSet.difference(head_anc, base_anc))
expected_behind = MapSet.size(MapSet.difference(base_anc, head_anc))
assert {ahead, behind} == {expected_ahead, expected_behind},
"iter=#{i}: ahead_behind(#{String.slice(base, 0, 7)}, #{String.slice(head, 0, 7)}) = #{inspect({ahead, behind})}, expected #{inspect({expected_ahead, expected_behind})}"
end
end
end
test "commits_between: set matches brute-force (ancestors(head) \\ ancestors(base))" do
ex_unit_seed = ExUnit.configuration()[:seed]
for i <- 1..@iterations do
:rand.seed(:exsss, {ex_unit_seed, i, 13})
repo = init_repo()
t = empty_tree_sha(repo)
{shas, tips} = random_dag(repo, t, 20)
for {sha, idx} <- Enum.with_index(shas),
do: :ok = ExGitObjectstore.create_branch(repo, "b-#{idx}", sha)
g = graph_of(repo)
pairs =
for a <- Enum.take_random(tips, min(3, length(tips))),
b <- Enum.take_random(tips, min(3, length(tips))),
a != b,
do: {a, b}
for {base, head} <- pairs do
{:ok, got} = Graph.commits_between(g, base, head)
expected =
MapSet.difference(
brute_force_ancestors(repo, head),
brute_force_ancestors(repo, base)
)
assert MapSet.new(got) == expected,
"iter=#{i}: commits_between(#{String.slice(base, 0, 7)}, #{String.slice(head, 0, 7)}) returned #{inspect(got)}, expected set #{inspect(MapSet.to_list(expected))}"
end
end
end
end
# --- random DAG + brute-force helpers ---
defp random_dag(repo, tree, n) do
{shas_rev, used_as_parent} =
Enum.reduce(1..n, {[], MapSet.new()}, fn i, {acc, pset} ->
parents = pick_random_parents(acc)
sha = commit(repo, tree, parents, 1_000_000 + i)
pset = Enum.reduce(parents, pset, &MapSet.put(&2, &1))
{[sha | acc], pset}
end)
all = Enum.reverse(shas_rev)
tips = Enum.reject(all, &MapSet.member?(used_as_parent, &1))
{all, tips}
end
defp pick_random_parents([]), do: []
defp pick_random_parents([only]), do: if(:rand.uniform() < 0.5, do: [only], else: [])
defp pick_random_parents(existing) when length(existing) >= 2 do
if :rand.uniform() < 0.2 do
existing |> Enum.shuffle() |> Enum.take(2)
else
[Enum.random(existing)]
end
end
defp brute_force_ancestors(repo, sha) do
do_bf_ancestors(repo, [sha], MapSet.new())
end
defp do_bf_ancestors(_repo, [], acc), do: acc
defp do_bf_ancestors(repo, [sha | rest], acc) do
if MapSet.member?(acc, sha) do
do_bf_ancestors(repo, rest, acc)
else
acc = MapSet.put(acc, sha)
parents =
case ExGitObjectstore.ObjectResolver.read(repo, sha) do
{:ok, %Commit{parents: ps}} -> ps
_ -> []
end
do_bf_ancestors(repo, parents ++ rest, acc)
end
end
defp brute_force_ancestor?(repo, anc, desc) do
MapSet.member?(brute_force_ancestors(repo, desc), anc)
end
end