ref:1f149a1f725cda30241b369a6a5bb3f38001552d

perf: graph cache mtime freshness + walk-base-once in fallback (#26)

Two related fixes from investigating fangorn/anvil's 2.6 s PR-list mount. Adds Storage.blob_fingerprint optional callback (Filesystem returns mtime+size, others :unsupported) so Graph.Cache.fetch/2 can return :stale when the on-disk graph has been rewritten by a separate VM. Without this, 'mix anvil.graphs.rebuild' from a separate shell never reaches the running app's persistent_term cache — that's why the rebuild was 'briefly effective then gone'. Adds Graph.Fallback.ahead_behind_many/4 (walks ancestors(base) once and reuses it across heads). The old fill_per_head/4 was O(N · |ancestors(base)|) which on fangorn/anvil with ~50 PRs against a few-hundred-commit base was the actual reason the slow path was multi-second, not just slow. API change: Graph.Cache.fetch/1 -> fetch/2 and put/2 -> put/3 (added fingerprint arg; pass Cache.no_fingerprint() for old behaviour). ## Test plan - [x] 924 tests / 0 failures - [x] New tests cover walk-once correctness (vs per-call), cache freshness transitions, and an end-to-end "separate-process rewrite triggers reload" integration test. - [x] mix format --check-formatted clean. - [x] No new credo issues in changed files.
SHA: 1f149a1f725cda30241b369a6a5bb3f38001552d
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-05-06 01:54
Parents: c38581b
9 files changed +451 -57
Type
lib/ex_git_objectstore.ex +38 −10
@@ -775,13 +775,23 @@
end)
end
# Heads not covered by the graph (or queries against a graph-less repo)
# land here. Naively iterating per-head re-walks ancestors(base_sha) for
# every head — that's O(N · |ancestors(base)|), which on a 50-PR /
# 400-commit base materially shows up as a multi-second LiveView mount.
# `Graph.Fallback.ahead_behind_many/4` walks base once and reuses the
# ancestor set, restoring graceful degradation.
defp fill_per_head(_repo, _base_sha, [], acc), do: acc
defp fill_per_head(repo, base_sha, head_shas, acc) do
Enum.reduce(head_shas, acc, fn head_sha, acc ->
case ahead_behind(repo, base_sha, head_sha) do
{:ok, counts} -> Map.put(acc, head_sha, counts)
{:error, _} -> acc
end
end)
case Graph.Fallback.ahead_behind_many(repo, base_sha, head_shas) do
{:ok, by_head} -> Map.merge(acc, by_head)
# Base walk failed — preserve prior behaviour by returning whatever
# the graph path already filled in. The caller's contract is "missing
# heads are omitted," so dropping all of them on a base failure is
# consistent.
{:error, _} -> acc
end
end
@doc """
@@ -815,6 +825,6 @@
def rebuild_graph(%Repo{} = repo) do
with {:ok, graph} <- Graph.build(repo),
:ok <- Graph.save(repo, graph) do
Graph.Cache.put(repo, graph, current_graph_fingerprint(repo))
Graph.Cache.put(repo, graph)
end
end
@@ -863,18 +873,36 @@
end
defp load_or_fetch_graph(repo) do
case Graph.Cache.fetch(repo) do
fp = current_graph_fingerprint(repo)
case Graph.Cache.fetch(repo, fp) do
{:ok, graph} ->
{:ok, graph}
other when other in [:miss, :stale] ->
:miss ->
case Graph.load(repo) do
{:ok, graph} ->
:ok = Graph.Cache.put(repo, graph)
:ok = Graph.Cache.put(repo, graph, fp)
{:ok, graph}
{:error, _} = err ->
err
end
end
end
# The fingerprint is `:no_fingerprint` when the storage backend can't
# cheaply detect changes (S3, Memory). Cache stays valid until an
# explicit `Graph.Cache.delete/1` (writers must coordinate). For
# Filesystem, a `mtime+size` tuple from `stat/2` does the job — one
# syscall per query, far cheaper than reloading the graph.
defp current_graph_fingerprint(%Repo{storage: {mod, _}} = repo) do
if function_exported?(mod, :blob_fingerprint, 3) do
case Repo.storage_call(repo, :blob_fingerprint, [Graph.blob_key()]) do
{:ok, fp} -> fp
{:error, _} -> Graph.Cache.no_fingerprint()
end
else
Graph.Cache.no_fingerprint()
end
end
lib/ex_git_objectstore/graph/cache.ex +53 −14
@@ -18,34 +18,66 @@
prefix}`. Backed by `:persistent_term` for zero-copy reads.
A graph is typically ~50 B per commit; a 100k-commit repo is 5 MB, which is
fine to keep hot. Writes trigger a global GC scan, so `put/2` should be called
fine to keep hot. Writes trigger a global GC scan, so `put/3` should be called
sparingly — once per rebuild, not once per query.
## Freshness check
Each cache entry is stored alongside an opaque storage-provided fingerprint
(file mtime+size for Filesystem, `:no_fingerprint` otherwise). `fetch/2`
takes the *current* fingerprint and returns `:stale` when it differs from
the cached one — letting the caller reload from disk.
Thread-safe: reads are lock-free; `put/2` and `delete/1` serialize through the
VM's persistent-term machinery. Callers that want build-then-cache semantics
should coordinate externally (`ExGitObjectstore.rebuild_graph/1` does this).
This is what stops `mix anvil.graphs.rebuild` running in a separate VM
from being a no-op: the rebuild rewrites the on-disk file (changing its
mtime), and the next `fetch/2` in the running app sees `:stale` and
reloads instead of returning the prior stale graph forever.
Storages that can't supply a cheap fingerprint (S3, Memory) pass
`:no_fingerprint` and rely on explicit `delete/1` for invalidation.
Thread-safe: reads are lock-free; `put/3` and `delete/1` serialize through
the VM's persistent-term machinery.
"""
alias ExGitObjectstore.{Graph, Repo}
@namespace {__MODULE__, :v2}
@no_fp :no_fingerprint
@typedoc "Opaque fingerprint provided by storage; compared by ==."
@namespace {__MODULE__, :v1}
@type fingerprint :: term()
@doc """
Fetch the cached graph for `repo`. Compares the supplied `current_fp`
against the fingerprint stored alongside the cached graph:
* `{:ok, graph}` — cache hit and fingerprint matches (or both sides are
`:no_fingerprint`, i.e. storage doesn't support freshness checks).
* `:stale` — cache hit but fingerprint mismatched. Caller should
Fetch the cached graph for `repo`, or `:miss` if none is cached.
reload and `put/3` with the new fingerprint.
* `:miss` — nothing cached.
"""
@spec fetch(Repo.t(), fingerprint()) :: {:ok, Graph.t()} | :stale | :miss
def fetch(%Repo{} = repo, current_fp) do
@spec fetch(Repo.t()) :: {:ok, Graph.t()} | :miss
def fetch(%Repo{} = repo) do
case :persistent_term.get(key(repo), :__miss__) do
:__miss__ ->
:miss
:__miss__ -> :miss
%Graph{} = g -> {:ok, g}
{%Graph{} = graph, ^current_fp} ->
{:ok, graph}
{%Graph{}, _other_fp} ->
:stale
end
end
@doc """
Cache `graph` for `repo`. Replaces any existing entry.
Cache `graph` for `repo` with the storage-provided `fingerprint`. Replaces
any existing entry.
"""
@spec put(Repo.t(), Graph.t()) :: :ok
def put(%Repo{} = repo, %Graph{} = graph) do
:persistent_term.put(key(repo), graph)
@spec put(Repo.t(), Graph.t(), fingerprint()) :: :ok
def put(%Repo{} = repo, %Graph{} = graph, fingerprint) do
:persistent_term.put(key(repo), {graph, fingerprint})
:ok
end
@@ -58,6 +90,13 @@
_ = :persistent_term.erase(key(repo))
:ok
end
@doc """
The sentinel used when storage doesn't support a freshness fingerprint.
Compares equal only to itself; cache hits always succeed in that case.
"""
@spec no_fingerprint() :: :no_fingerprint
def no_fingerprint, do: @no_fp
defp key(%Repo{storage: {mod, _cfg}} = repo) do
{@namespace, mod, Repo.prefix(repo)}
lib/ex_git_objectstore/graph/fallback.ex +50 −0
@@ -52,6 +52,56 @@
end
end
@doc """
Walk ancestors(base_sha) **once** and reuse the set across every head.
The naive caller pattern — calling `ahead_behind/4` once per head — is
O(N · |ancestors(base)|) because each call independently walks the base
history. This walks base once and then walks each head with early
termination when entries already in `base_anc` are reached, dropping the
cost to O(|ancestors(base)| + Σ |ancestors(head_i) \\ ancestors(base)|).
Returns `{:ok, %{head_sha => %{ahead: N, behind: M}}}` with one entry per
head whose walk succeeded. Heads whose walk fails (e.g. missing object)
are silently dropped — the caller can fall back to per-head if needed,
or just treat them as zero.
If the **base** walk fails the whole call returns `{:error, reason}`,
matching `ahead_behind/4`'s contract.
"""
@spec ahead_behind_many(Repo.t(), String.t(), [String.t()], opts()) ::
{:ok, %{String.t() => %{ahead: non_neg_integer(), behind: non_neg_integer()}}}
| {:error, term()}
def ahead_behind_many(%Repo{} = repo, base_sha, head_shas, opts \\ [])
when is_list(head_shas) do
max_walk = Keyword.get(opts, :max_walk, @default_max_walk)
with {:ok, base_anc} <- collect_ancestors(repo, base_sha, max_walk) do
base_size = MapSet.size(base_anc)
result =
Enum.reduce(head_shas, %{}, fn head_sha, acc ->
cond do
head_sha == base_sha ->
Map.put(acc, head_sha, %{ahead: 0, behind: 0})
true ->
case collect_ancestors(repo, head_sha, max_walk) do
{:ok, head_anc} ->
ahead = MapSet.size(MapSet.difference(head_anc, base_anc))
behind = base_size - MapSet.size(MapSet.intersection(base_anc, head_anc))
Map.put(acc, head_sha, %{ahead: ahead, behind: behind})
{:error, _} ->
acc
end
end
end)
{:ok, result}
end
end
@spec commits_between(Repo.t(), String.t(), String.t(), opts()) ::
{:ok, [String.t()]} | {:error, term()}
def commits_between(%Repo{} = repo, base_sha, head_sha, opts \\ []) do
lib/ex_git_objectstore/storage.ex +20 −0
@@ -75,4 +75,24 @@
@callback put_blob(config, prefix, blob_key, binary()) :: :ok | {:error, term()}
@callback delete_blob(config, prefix, blob_key) :: :ok | {:error, term()}
@callback blob_exists?(config, prefix, blob_key) :: boolean()
@doc """
Optional. Cheap content-change fingerprint for `blob_key` — used by
`ExGitObjectstore.Graph.Cache` to detect when a fresh on-disk graph has
been written by a separate VM (e.g. `mix anvil.graphs.rebuild`) so the
in-process `:persistent_term` cache reloads instead of returning stale
data forever.
Implementations should be **local-only** — no network round-trip per
call. Filesystem returns `{mtime, size}` from `stat/2`. S3 returns
`{:error, :unsupported}`; cache invalidation there relies on the writer
calling `Graph.Cache.delete/1` after `put_blob`.
Returning `{:error, :not_found}` is allowed for blobs that don't exist;
the caller treats it like an unsupported response (cache hit stands).
"""
@callback blob_fingerprint(config, prefix, blob_key) ::
{:ok, term()} | {:error, :unsupported | :not_found | term()}
@optional_callbacks [blob_fingerprint: 3]
end
lib/ex_git_objectstore/storage/filesystem.ex +11 −0
@@ -393,6 +393,17 @@
File.exists?(blob_path(config, prefix, blob_key))
end
@impl true
def blob_fingerprint(config, prefix, blob_key) do
path = blob_path(config, prefix, blob_key)
case File.stat(path, time: :posix) do
{:ok, %File.Stat{mtime: mtime, size: size}} -> {:ok, {mtime, size}}
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
# -- Private --
defp object_path(config, prefix, sha) do
test/ex_git_objectstore/graph/cache_freshness_test.exs +106 −0
@@ -1,0 +1,106 @@
# 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.CacheFreshnessTest do
# End-to-end test: a separate "process" rewriting the on-disk graph blob
# must invalidate the in-VM Cache so subsequent queries reload. This is
# what makes manual `mix anvil.graphs.rebuild` actually take effect in
# the running prod app — without the fingerprint check, persistent_term
# never sees the newer file.
use ExUnit.Case, async: false
alias ExGitObjectstore.{Graph, Repo, Storage.Filesystem}
alias ExGitObjectstore.Graph.Cache
alias ExGitObjectstore.Object.{Commit, Tree}
setup do
# tmp_dir/0 / a unique subtree under System.tmp_dir!() so the on-disk
# blob mtime is observable by `File.stat`.
root =
Path.join([
System.tmp_dir!(),
"ex_git_objectstore_freshness",
Integer.to_string(:erlang.unique_integer([:positive]))
])
File.mkdir_p!(root)
File.mkdir_p!(Path.join(root, "repos"))
repo_id = "fresh-#{:erlang.unique_integer([:positive])}"
repo = Repo.new(repo_id, storage: {Filesystem, %{root: root}})
ExGitObjectstore.init(repo)
on_exit(fn ->
Cache.delete(repo)
File.rm_rf!(root)
end)
%{repo: repo}
end
test "after a separate process rewrites the on-disk graph, the in-VM cache reloads",
%{repo: repo} do
# Set up a single-commit repo and prime the on-disk graph + cache.
{:ok, tree} = ExGitObjectstore.Object.write(repo, Tree.new([]))
{:ok, c1} =
ExGitObjectstore.Object.write(repo, %Commit{
tree: tree,
parents: [],
author: "A <a@a.com> 1 +0000",
committer: "A <a@a.com> 1 +0000",
message: "c1\n"
})
:ok = ExGitObjectstore.create_branch(repo, "main", c1)
:ok = ExGitObjectstore.rebuild_graph(repo)
# Confirm c1 is in the cache as expected.
{:ok, graph_v1} = Graph.load(repo)
assert Graph.member?(graph_v1, c1)
assert Graph.size(graph_v1) == 1
# Simulate a separate process: build a graph that now contains an
# additional commit and persist it to disk WITHOUT going through
# `rebuild_graph` (which would update Cache in-process). This is the
# mix-task-from-another-VM scenario.
{:ok, c2} =
ExGitObjectstore.Object.write(repo, %Commit{
tree: tree,
parents: [c1],
author: "A <a@a.com> 2 +0000",
committer: "A <a@a.com> 2 +0000",
message: "c2\n"
})
:ok = ExGitObjectstore.update_branch(repo, "main", c2, c1)
# Bump the file mtime to be strictly newer (1-second filesystem
# resolution is common; touching with a forced future timestamp avoids
# a same-second collision in fast tests).
blob_path = Path.join([root_of(repo), "repos", repo.id, "blobs", Graph.blob_key()])
{:ok, fresh_graph} = Graph.build(repo)
:ok = Graph.save(repo, fresh_graph)
File.touch!(blob_path, :erlang.system_time(:second) + 5)
# Issue a query that goes through `load_or_fetch_graph`. Without the
# freshness check, the cache still has graph_v1 and the answer would
# claim c2 isn't in the graph. With the check, the mismatched
# fingerprint triggers a reload.
{:ok, true} = ExGitObjectstore.ancestor?(repo, c1, c2)
{:ok, %{ahead: 1, behind: 0}} = ExGitObjectstore.ahead_behind(repo, c1, c2)
end
defp root_of(%Repo{storage: {Filesystem, %{root: root}}}), do: root
end
test/ex_git_objectstore/graph/cache_test.exs +73 −31
@@ -26,46 +26,88 @@
%{repo: repo}
end
test ":miss when nothing cached", %{repo: repo} do
assert :miss = Cache.fetch(repo)
end
describe "fetch/2 with no_fingerprint storage (Memory, S3, etc.)" do
test ":miss when nothing cached", %{repo: repo} do
assert :miss = Cache.fetch(repo, Cache.no_fingerprint())
end
test "put then fetch returns the same graph", %{repo: repo} do
g = %Graph{version: 1, shas: ["a"], by_sha: %{"a" => :placeholder}}
:ok = Cache.put(repo, g)
assert {:ok, ^g} = Cache.fetch(repo)
end
test "put then fetch returns the same graph", %{repo: repo} do
g = %Graph{version: 1, shas: ["a"], by_sha: %{"a" => :placeholder}}
:ok = Cache.put(repo, g, Cache.no_fingerprint())
assert {:ok, ^g} = Cache.fetch(repo, Cache.no_fingerprint())
end
test "put overwrites", %{repo: repo} do
g1 = %Graph{version: 1, shas: ["a"], by_sha: %{}}
g2 = %Graph{version: 1, shas: ["b"], by_sha: %{}}
test "put overwrites", %{repo: repo} do
g1 = %Graph{version: 1, shas: ["a"], by_sha: %{}}
g2 = %Graph{version: 1, shas: ["b"], by_sha: %{}}
:ok = Cache.put(repo, g1)
:ok = Cache.put(repo, g2)
assert {:ok, ^g2} = Cache.fetch(repo)
:ok = Cache.put(repo, g1, Cache.no_fingerprint())
:ok = Cache.put(repo, g2, Cache.no_fingerprint())
assert {:ok, ^g2} = Cache.fetch(repo, Cache.no_fingerprint())
end
end
test "delete evicts", %{repo: repo} do
:ok = Cache.put(repo, %Graph{}, Cache.no_fingerprint())
test "delete evicts", %{repo: repo} do
:ok = Cache.put(repo, %Graph{})
:ok = Cache.delete(repo)
assert :miss = Cache.fetch(repo)
end
:ok = Cache.delete(repo)
assert :miss = Cache.fetch(repo, Cache.no_fingerprint())
end
test "delete on unmapped key is :ok", %{repo: repo} do
assert :ok = Cache.delete(repo)
end
test "entries are keyed per repo", %{repo: repo_a} do
test "delete on unmapped key is :ok", %{repo: repo} do
assert :ok = Cache.delete(repo)
repo_b = RepoHelper.memory_repo("cache-#{:erlang.unique_integer([:positive])}")
on_exit(fn -> Cache.delete(repo_b) end)
ga = %Graph{version: 1, shas: ["a"], by_sha: %{}}
gb = %Graph{version: 1, shas: ["b"], by_sha: %{}}
:ok = Cache.put(repo_a, ga, Cache.no_fingerprint())
:ok = Cache.put(repo_b, gb, Cache.no_fingerprint())
assert {:ok, ^ga} = Cache.fetch(repo_a, Cache.no_fingerprint())
assert {:ok, ^gb} = Cache.fetch(repo_b, Cache.no_fingerprint())
end
end
test "entries are keyed per repo", %{repo: repo_a} do
repo_b = RepoHelper.memory_repo("cache-#{:erlang.unique_integer([:positive])}")
on_exit(fn -> Cache.delete(repo_b) end)
describe "fetch/2 freshness check" do
test "matching fingerprint returns cache hit", %{repo: repo} do
g = %Graph{version: 1, shas: ["a"], by_sha: %{}}
fp = {1_700_000_000, 4096}
:ok = Cache.put(repo, g, fp)
assert {:ok, ^g} = Cache.fetch(repo, fp)
ga = %Graph{version: 1, shas: ["a"], by_sha: %{}}
gb = %Graph{version: 1, shas: ["b"], by_sha: %{}}
end
:ok = Cache.put(repo_a, ga)
:ok = Cache.put(repo_b, gb)
test "different fingerprint returns :stale", %{repo: repo} do
g = %Graph{version: 1, shas: ["a"], by_sha: %{}}
old_fp = {1_700_000_000, 4096}
new_fp = {1_700_000_001, 4096}
assert {:ok, ^ga} = Cache.fetch(repo_a)
assert {:ok, ^gb} = Cache.fetch(repo_b)
:ok = Cache.put(repo, g, old_fp)
assert :stale = Cache.fetch(repo, new_fp)
end
test "transition from real fingerprint to no_fingerprint is :stale", %{repo: repo} do
g = %Graph{version: 1, shas: ["a"], by_sha: %{}}
:ok = Cache.put(repo, g, {1_700_000_000, 4096})
assert :stale = Cache.fetch(repo, Cache.no_fingerprint())
end
test "after :stale, put with new fingerprint restores hit", %{repo: repo} do
g_old = %Graph{version: 1, shas: ["a"], by_sha: %{}}
g_new = %Graph{version: 1, shas: ["b"], by_sha: %{}}
old_fp = {1_700_000_000, 4096}
new_fp = {1_700_000_001, 4096}
:ok = Cache.put(repo, g_old, old_fp)
assert :stale = Cache.fetch(repo, new_fp)
# Caller would now reload + put with the new fingerprint.
:ok = Cache.put(repo, g_new, new_fp)
assert {:ok, ^g_new} = Cache.fetch(repo, new_fp)
end
end
end
test/ex_git_objectstore/graph/fallback_test.exs +98 −0
@@ -109,6 +109,104 @@
end
end
describe "ahead_behind_many/4" do
test "shared base linear divergence — base walked once" do
repo = init_repo()
t = tree!(repo)
# base history: b1 → b2
b1 = commit!(repo, t, [], 1)
b2 = commit!(repo, t, [b1], 2)
# head_a one-ahead, head_b two-ahead
head_a = commit!(repo, t, [b2], 3)
head_b1 = commit!(repo, t, [b2], 4)
head_b2 = commit!(repo, t, [head_b1], 5)
{:ok, by_head} = Fallback.ahead_behind_many(repo, b2, [head_a, head_b2])
assert by_head[head_a] == %{ahead: 1, behind: 0}
assert by_head[head_b2] == %{ahead: 2, behind: 0}
end
test "head behind base reports correct behind count" do
repo = init_repo()
t = tree!(repo)
b1 = commit!(repo, t, [], 1)
b2 = commit!(repo, t, [b1], 2)
b3 = commit!(repo, t, [b2], 3)
# head sits at b1 — 0 ahead, 2 behind from b3
{:ok, by_head} = Fallback.ahead_behind_many(repo, b3, [b1])
assert by_head[b1] == %{ahead: 0, behind: 2}
end
test "head equal to base is 0/0" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
assert {:ok, %{^a => %{ahead: 0, behind: 0}}} = Fallback.ahead_behind_many(repo, a, [a])
end
test "missing head is dropped, others succeed" do
repo = init_repo()
t = tree!(repo)
base = commit!(repo, t, [], 1)
head = commit!(repo, t, [base], 2)
missing = String.duplicate("0", 40)
{:ok, by_head} = Fallback.ahead_behind_many(repo, base, [head, missing])
assert Map.has_key?(by_head, head)
refute Map.has_key?(by_head, missing)
end
test "empty head list → empty map" do
repo = init_repo()
t = tree!(repo)
base = commit!(repo, t, [], 1)
assert {:ok, %{}} = Fallback.ahead_behind_many(repo, base, [])
end
test "missing base propagates as error" do
repo = init_repo()
missing = String.duplicate("0", 40)
assert {:error, _} = Fallback.ahead_behind_many(repo, missing, [missing])
end
test "agrees with per-call ahead_behind on every head (random divergence)" do
repo = init_repo()
t = tree!(repo)
# 5-commit base, 4 divergent heads each branching at different points.
base_chain = build_linear_chain(repo, t, 5)
base = List.last(base_chain)
heads =
for {parent, depth} <- Enum.zip(base_chain, [3, 1, 2, 4]) do
extend_chain(repo, t, parent, depth)
end
{:ok, batch} = Fallback.ahead_behind_many(repo, base, heads)
for head <- heads do
{:ok, single} = Fallback.ahead_behind(repo, base, head)
assert batch[head] == single, "mismatch for head #{head}"
end
end
end
defp build_linear_chain(repo, tree, n) do
Enum.reduce(1..n, [], fn i, acc ->
parents = if acc == [], do: [], else: [List.last(acc)]
acc ++ [commit!(repo, tree, parents, i)]
end)
end
defp extend_chain(repo, tree, parent, n) do
Enum.reduce(1..n, parent, fn i, p -> commit!(repo, tree, [p], 1000 + i) end)
end
describe "ancestor?/4" do
test "self is ancestor" do
repo = init_repo()
test/ex_git_objectstore/graph_integration_test.exs +2 −2
@@ -97,7 +97,7 @@
:ok = ExGitObjectstore.create_branch(repo, "main", a)
:ok = ExGitObjectstore.rebuild_graph(repo)
assert {:ok, _graph} = Cache.fetch(repo)
assert {:ok, _graph} = Cache.fetch(repo, Cache.no_fingerprint())
end
test "after rebuild, queries hit the graph path and return the same answers", %{repo: repo} do
@@ -263,7 +263,7 @@
# nothing just yields an empty graph — that's expected. The negative
# case here is that rebuild_graph/1 succeeds without crashing.
assert :ok = ExGitObjectstore.rebuild_graph(repo)
assert {:ok, graph} = Cache.fetch(repo)
assert {:ok, graph} = Cache.fetch(repo, Cache.no_fingerprint())
assert ExGitObjectstore.Graph.size(graph) == 0
end
end