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