ref:37bb565ea81d8b8b205000801431400cfd07e3eb

feat: Graph queries — ancestor?, ahead_behind, commits_between (#16)

PR 2 of 4 for fangorn/ex_git_objectstore#26. Adds the query API that makes the commit-graph useful to Anvil's PR list and PR show paths (fangorn/anvil#55). Strictly additive — no callers yet. ## Queries - **\`Graph.ancestor?/3\`** — generation-pruned BFS from descendant back. If \`gen(ancestor) > gen(descendant)\`, return false immediately; otherwise walk parents, skipping any SHA with generation less than \`gen(ancestor)\`. - **\`Graph.ahead_behind/3\`** — generation-ordered priority queue. Tag each SHA with \`:a\` / \`:b\` / \`:both\`; commits marked \`:both\` (and their transitive ancestors) are excluded from both counts. - **\`Graph.commits_between/3\`** — the head-side set from the ahead_behind walk, sorted newest-first by corrected commit date. Each returns \`{:error, :missing_commit}\` if either SHA isn't in the graph — callers fall back to a reference walker. ## Tests 45 graph tests total. New: - 5 \`ancestor?\` (self, parent→child, unrelated, merge parents, missing) - 7 \`ahead_behind\` (identical, linear, diverged, merge-base on both sides, disjoint roots, shared-merge non-double-counting, missing) - 4 \`commits_between\` (empty, linear, excludes base ancestors, merge with ordering) - 3 equivalence tests × 10 random DAGs each, comparing every pair against a brute-force \`cat_object\` walker Full suite: **681 tests, 0 failures** (was 661 on main post-#15). Credo: unchanged from main. ## Benchmark \`bench/graph_build.exs\` extended. 5000-commit linear chain, in-memory storage: \`\`\` Walker: full ancestry scan (for ahead_behind) 57 ms Graph.ahead_behind (in-memory) 6 ms (9.4x) Graph.commits_between (in-memory) 7 ms \`\`\` On S3, where each \`cat_object\` is a network round-trip, the walker scales with \`commit_count × latency\` while \`Graph.ahead_behind\` is bounded by the in-memory walk length. That is the gap Anvil's prod is currently paying for. ## What's next - PR 3: top-level \`ExGitObjectstore.{ahead_behind, commits_between, ancestor?}\` with auto-load + fallback to the existing walker when the graph is missing or a SHA isn't present yet. Plus \`ExGitObjectstore.rebuild_graph/1\` for explicit seeding. - Anvil PR: swap \`lib/anvil/git/objectstore.ex\` to the new API + a Mix task to seed graphs in dev/prod. - (Later) PR 4: incremental \`Graph.update/3\` wired into the write paths.
SHA: 37bb565ea81d8b8b205000801431400cfd07e3eb
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-18 16:52
Parents: 330a5b0
3 files changed +676 -3
Type
bench/graph_build.exs +35 −3
@@ -123,7 +123,7 @@
IO.puts(" (graph contains #{Graph.size(graph)} commits)")
# 3) Persist + reload (future fast path — no cat_object calls).
# 3) Persist + reload (steady-state fast path — no cat_object calls).
{:ok, load_ms} =
time("Graph.save + Graph.load (persist + rehydrate)", fn ->
:ok = Graph.save(repo, graph)
@@ -131,9 +131,41 @@
:ok
end)
|> then(fn {_, ms} -> {:ok, ms} end)
# 4) Query paths on a loaded graph vs. the walker.
root = walk_to_root(repo, tip)
{_, walker_ab_ms} =
time("Walker: full ancestry scan (for ahead_behind)", fn ->
Bench.Walker.walk_all_ancestors(repo, tip)
Bench.Walker.walk_all_ancestors(repo, root)
end)
IO.puts("\n walker_ms / build_ms = #{Float.round(walker_ms / max(build_ms, 0.1), 2)}x")
IO.puts(" walker_ms / load_ms = #{Float.round(walker_ms / max(load_ms, 0.1), 2)}x")
{_, graph_ab_ms} =
time("Graph.ahead_behind (in-memory)", fn ->
{:ok, _} = Graph.ahead_behind(graph, root, tip)
end)
{_, graph_between_ms} =
time("Graph.commits_between (in-memory)", fn ->
{:ok, _} = Graph.commits_between(graph, root, tip)
end)
IO.puts("\n walker_ms / build_ms = #{Float.round(walker_ms / max(build_ms, 0.1), 2)}x")
IO.puts(" walker_ms / load_ms = #{Float.round(walker_ms / max(load_ms, 0.1), 2)}x")
IO.puts(
" walker_ab_ms / graph_ab_ms = #{Float.round(walker_ab_ms / max(graph_ab_ms, 0.01), 2)}x"
)
_ = graph_between_ms
IO.puts("")
end
defp walk_to_root(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{parents: []}} -> sha
{:ok, %Commit{parents: [p | _]}} -> walk_to_root(repo, p)
end
end
end
lib/ex_git_objectstore/graph.ex +175 −0
@@ -134,4 +134,179 @@
"""
@spec size(t()) :: non_neg_integer()
def size(%__MODULE__{by_sha: by_sha}), do: map_size(by_sha)
# -- Query API --------------------------------------------------------
#
# These queries operate entirely in memory on the loaded graph. They
# return `{:error, :missing_commit}` if either input SHA is not in the
# graph — callers can then fall back to a reference walker.
@doc """
`true` when `ancestor_sha` is reachable from `descendant_sha` through
the parent chain (or equal to it). Uses the generation number to prune
any branch whose generation is strictly less than `gen(ancestor_sha)`.
"""
@spec ancestor?(t(), sha(), sha()) :: {:ok, boolean()} | {:error, :missing_commit}
def ancestor?(%__MODULE__{by_sha: by_sha}, ancestor_sha, descendant_sha) do
with {:ok, anc_entry} <- fetch_entry(by_sha, ancestor_sha),
{:ok, desc_entry} <- fetch_entry(by_sha, descendant_sha) do
cond do
ancestor_sha == descendant_sha ->
{:ok, true}
anc_entry.generation > desc_entry.generation ->
{:ok, false}
true ->
{:ok, bfs_find(by_sha, [descendant_sha], %{}, ancestor_sha, anc_entry.generation)}
end
end
end
@doc """
Count commits reachable from `head_sha` but not from `base_sha`
(`ahead`) and vice versa (`behind`). Equivalent to
`git rev-list --count --left-right base...head` modulo order.
"""
@spec ahead_behind(t(), sha(), sha()) ::
{:ok, %{ahead: non_neg_integer(), behind: non_neg_integer()}}
| {:error, :missing_commit}
def ahead_behind(%__MODULE__{by_sha: by_sha}, base_sha, head_sha) do
with {:ok, _} <- fetch_entry(by_sha, base_sha),
{:ok, _} <- fetch_entry(by_sha, head_sha) do
{ahead_map, behind_map} = ahead_behind_sets(by_sha, base_sha, head_sha)
{:ok, %{ahead: map_size(ahead_map), behind: map_size(behind_map)}}
end
end
@doc """
Return commits reachable from `head_sha` but not from `base_sha`, in
descending corrected-commit-date order (newest first). Empty when
`head_sha` is an ancestor of (or equal to) `base_sha`.
"""
@spec commits_between(t(), sha(), sha()) :: {:ok, [sha()]} | {:error, :missing_commit}
def commits_between(%__MODULE__{by_sha: by_sha}, base_sha, head_sha) do
with {:ok, _} <- fetch_entry(by_sha, base_sha),
{:ok, _} <- fetch_entry(by_sha, head_sha) do
{ahead_map, _behind_map} = ahead_behind_sets(by_sha, base_sha, head_sha)
sorted =
ahead_map
|> Map.keys()
|> Enum.sort_by(&(-Map.fetch!(by_sha, &1).corrected_commit_date))
{:ok, sorted}
end
end
# -- Internal helpers -------------------------------------------------
defp fetch_entry(by_sha, sha) do
case Map.fetch(by_sha, sha) do
{:ok, entry} -> {:ok, entry}
:error -> {:error, :missing_commit}
end
end
# Breadth-first walk from `frontier` toward roots, looking for `target`.
# Pruned: any SHA whose generation is < `min_gen`. `min_gen` is the
# generation of `target` — anything older cannot reach it. `seen` is a
# plain `%{sha => true}` map (see note on ahead_behind_sets/3).
defp bfs_find(_by_sha, [], _seen, _target, _min_gen), do: false
defp bfs_find(by_sha, [sha | rest], seen, target, min_gen) do
cond do
sha == target ->
true
Map.has_key?(seen, sha) ->
bfs_find(by_sha, rest, seen, target, min_gen)
true ->
entry = Map.fetch!(by_sha, sha)
if entry.generation < min_gen do
bfs_find(by_sha, rest, Map.put(seen, sha, true), target, min_gen)
else
next = entry.parents ++ rest
bfs_find(by_sha, next, Map.put(seen, sha, true), target, min_gen)
end
end
end
# Compute `ahead_set = ancestors(head) \ ancestors(base)` and
# `behind_set = ancestors(base) \ ancestors(head)` with a single walk
# that tags each SHA by which side(s) reach it.
#
# Algorithm: generation-ordered priority queue. Pop the highest-gen
# entry; each SHA is processed exactly once (by its terminal side
# marker). Walk parents, merging side markers. A SHA marked `:both`
# does not contribute to ahead/behind, and neither do its ancestors
# (they inherit `:both`).
#
# `popped`, `ahead`, `behind` are plain `%{sha => true}` maps used as
# sets — MapSet would work, but its opaque type trips dialyzer when
# threaded through multiple internal clauses.
defp ahead_behind_sets(_by_sha, same, same), do: {%{}, %{}}
defp ahead_behind_sets(by_sha, base_sha, head_sha) do
mark = %{base_sha => :b, head_sha => :a}
queue = enqueue_many(by_sha, [base_sha, head_sha], [])
walk_ahead_behind(by_sha, queue, mark, %{}, %{}, %{})
end
defp walk_ahead_behind(_by, [], _mark, _popped, ahead, behind), do: {ahead, behind}
defp walk_ahead_behind(by, [{_gen, sha} | rest], mark, popped, ahead, behind) do
if Map.has_key?(popped, sha) do
walk_ahead_behind(by, rest, mark, popped, ahead, behind)
else
popped = Map.put(popped, sha, true)
side = Map.fetch!(mark, sha)
{ahead, behind} = tally(side, sha, ahead, behind)
{rest, mark} = push_parents(by, sha, side, rest, mark)
walk_ahead_behind(by, rest, mark, popped, ahead, behind)
end
end
defp tally(:a, sha, ahead, behind), do: {Map.put(ahead, sha, true), behind}
defp tally(:b, sha, ahead, behind), do: {ahead, Map.put(behind, sha, true)}
defp tally(:both, _sha, ahead, behind), do: {ahead, behind}
defp push_parents(by, sha, side, queue, mark) do
%{parents: parents} = Map.fetch!(by, sha)
Enum.reduce(parents, {queue, mark}, fn parent, {q, m} ->
new_side = combine_side(Map.get(m, parent), side)
m = Map.put(m, parent, new_side)
q = insert_by_gen(q, {Map.fetch!(by, parent).generation, parent})
{q, m}
end)
end
defp combine_side(nil, side), do: side
defp combine_side(:both, _), do: :both
defp combine_side(same, same), do: same
defp combine_side(_, _), do: :both
defp enqueue_many(by, shas, queue) do
Enum.reduce(shas, queue, fn sha, q ->
insert_by_gen(q, {Map.fetch!(by, sha).generation, sha})
end)
end
# Descending-generation ordered list. Same-generation entries keep
# insertion order (FIFO within a generation) — matches the
# commit_list_insert_by_date pattern used elsewhere in the library.
defp insert_by_gen([], item), do: [item]
defp insert_by_gen([head | rest] = list, item) do
if elem(item, 0) > elem(head, 0) do
[item | list]
else
[head | insert_by_gen(rest, item)]
end
end
end
test/ex_git_objectstore/graph/queries_test.exs +466 −0
@@ -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