ref:0bf85c603eeb8df5b51853347aa48cd7a69a1615

feat(walk): rev_list_range/4 — base..head with exclude_merges (#47)

Adds `ExGitObjectstore.rev_list_range/4`: commits reachable from `head` but not `base` (the `base..head` range), topological order oldest-first, with an `:exclude_merges` option. Combined, these linearize a range exactly the way `git rebase` (without `--rebase-merges`) replays it: merge commits are dropped while the commits they merged in — reachable via any parent — are kept. ## Why Anvil's rebase merge strategy currently builds its replay list by walking the head branch first-parent-only and **includes merge commits**, then cherry-picks each. Cherry-picking a merge commit fails with `{:merge_commit_needs_mainline, sha}`, so any PR whose head history contains a merge commit 422s on rebase-merge instead of merging or reporting a conflict. This primitive is the plumbing Anvil needs to flatten `base..head` correctly. See fangorn/anvil#337. ## Implementation - Bounded two-color `limit_list` painting (INTERESTING from head, UNINTERESTING from base; `:u` dominates), stopping once no interesting commit remains in the frontier — touches only the range plus its immediate boundary, not all of base's history. - Topological oldest-first via Kahn's algorithm with a `(committer-date, sha)` ready-queue for deterministic ordering. ## Tests - 8 new tests in `walk_test.exs`: diamond ordering, `exclude_merges` keeps both merge sides, empty range, linear range, and the integration-branch regression (an integration branch built by merging feature branches into main replays cleanly onto base with all content, instead of `:merge_commit_needs_mainline`). - Full suite green; `mix credo --strict` clean on changed files; `mix format` clean.
SHA: 0bf85c603eeb8df5b51853347aa48cd7a69a1615
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-10 16:26
Parents: c9e2814
4 files changed +507 -16
Type
lib/ex_git_objectstore.ex +59 −16
@@ -678,5 +678,48 @@
end
@doc """
Commits reachable from `head_sha` but not from `base_sha` (the `base..head`
range), in topological order oldest-first.
Equivalent to `git rev-list --topo-order --reverse base..head`. With
`exclude_merges: true`, merge commits (2+ parents) are dropped while the
commits they merged in are kept — linearizing the range the way `git rebase`
(without `--rebase-merges`) replays it. The result is safe to pass directly
to `rebase_commits/4`.
Uses the persisted commit-graph index when available (range set from a
generation-pruned walk, ordered by corrected commit date — immune to
committer-date skew). Falls back to `ExGitObjectstore.Walk.rev_list_range/4`,
a structural walker, when the graph isn't built or doesn't yet cover one of
the SHAs.
Emits `[:ex_git_objectstore, :graph, :query]` telemetry with
`operation: :rev_list_range` and `path: :graph | :fallback`.
## Options
* `:exclude_merges` — drop commits with 2+ parents (default: `false`)
"""
@spec rev_list_range(Repo.t(), sha(), sha(), keyword()) ::
{:ok, [{sha(), Commit.t()}]} | {:error, term()}
def rev_list_range(repo, base_sha, head_sha, opts \\ [])
def rev_list_range(%Repo{}, base_sha, base_sha, _opts), do: {:ok, []}
def rev_list_range(%Repo{} = repo, base_sha, head_sha, opts) do
routed_query(
repo,
:rev_list_range,
fn graph ->
graph_result(
graph,
[base_sha, head_sha],
&Walk.rev_list_range_graph(repo, &1, base_sha, head_sha, opts)
)
end,
fn -> Walk.rev_list_range(repo, base_sha, head_sha, opts) end
)
end
@doc """
True if `ancestor` is an ancestor of `descendant` (inclusive — a commit is
its own ancestor).
@@ -756,23 +799,23 @@
metadata = %{operation: :ahead_behind_many, repo_id: repo.id}
Telemetry.span([:ex_git_objectstore, :graph, :query], metadata, fn ->
case load_or_fetch_graph(repo) do
{merged, path} = ahead_behind_many_routed(repo, base_sha, head_shas)
{{:ok, merged}, Map.put(metadata, :path, path)}
end)
end
{:ok, graph} ->
if Graph.member?(graph, base_sha) do
{:ok, fast} = Graph.ahead_behind_many(graph, base_sha, head_shas)
missing = Enum.reject(head_shas, &Map.has_key?(fast, &1))
merged = fill_per_head(repo, base_sha, missing, fast)
{{:ok, merged}, Map.put(metadata, :path, :graph)}
else
merged = fill_per_head(repo, base_sha, head_shas, %{})
{{:ok, merged}, Map.put(metadata, :path, :fallback)}
end
# Batched graph fast path when the graph covers `base_sha` (per-head
# misses filled by the walker); otherwise everything goes per-head.
{:error, _} ->
defp ahead_behind_many_routed(repo, base_sha, head_shas) do
merged = fill_per_head(repo, base_sha, head_shas, %{})
{{:ok, merged}, Map.put(metadata, :path, :fallback)}
end
end)
with {:ok, graph} <- load_or_fetch_graph(repo),
true <- Graph.member?(graph, base_sha) do
{:ok, fast} = Graph.ahead_behind_many(graph, base_sha, head_shas)
missing = Enum.reject(head_shas, &Map.has_key?(fast, &1))
{fill_per_head(repo, base_sha, missing, fast), :graph}
else
_no_graph_or_missing_base ->
{fill_per_head(repo, base_sha, head_shas, %{}), :fallback}
end
end
# Heads not covered by the graph (or queries against a graph-less repo)
lib/ex_git_objectstore/walk.ex +150 −0
@@ -23,5 +23,6 @@
are requested, making offset-based pagination stable across merge histories.
"""
alias ExGitObjectstore.Graph
alias ExGitObjectstore.Object.Commit
alias ExGitObjectstore.{ObjectResolver, Repo}
@@ -262,6 +263,155 @@
_ ->
{queue, seen}
end
end
@doc """
Commits reachable from `head_sha` but not from `base_sha` (the `base..head`
range), in topological order oldest-first — every parent precedes its
children. Equivalent to `git rev-list --topo-order --reverse base..head`.
A commit is in the range iff it is reachable from `head_sha` and is **not**
an ancestor of `base_sha`. `base_sha` itself and all of its ancestors are
excluded.
With `exclude_merges: true`, commits with 2+ parents are dropped from the
result (like `git rev-list --no-merges`). Combined with the topological
order, this linearizes the range exactly the way `git rebase` (without
`--rebase-merges`) replays it: the merge commits are dropped but the
commits they merged in — reachable via any parent — are kept.
## Options
* `:exclude_merges` — drop commits with 2+ parents (default: `false`)
## Implementation
This is the structural **fallback** used when no commit-graph covers the
SHAs — `ExGitObjectstore.rev_list_range/4` routes to the generation-number
path (`rev_list_range_graph/5`) when a graph is available. The range set is
`ancestors(head) \\ ancestors(base)`, computed by
`ExGitObjectstore.Graph.Fallback.commits_between/3` — a plain
parent-structure BFS, immune to committer-date skew — then topologically
ordered oldest-first with Kahn's algorithm over the in-range subgraph.
"""
@spec rev_list_range(Repo.t(), String.t(), String.t(), keyword()) ::
{:ok, [{String.t(), Commit.t()}]} | {:error, term()}
def rev_list_range(repo, base_sha, head_sha, opts \\ [])
def rev_list_range(%Repo{}, base_sha, base_sha, _opts), do: {:ok, []}
def rev_list_range(%Repo{} = repo, base_sha, head_sha, opts) do
with {:ok, shas} <- Graph.Fallback.commits_between(repo, base_sha, head_sha),
{:ok, commits} <- load_range_commits(repo, shas) do
ordered =
shas
|> MapSet.new()
|> topo_oldest_first(commits)
|> maybe_drop_merges(Keyword.get(opts, :exclude_merges, false))
{:ok, ordered}
end
end
@doc """
Commit-graph path for `base..head` (same contract as `rev_list_range/4`).
The range set comes from `ExGitObjectstore.Graph.commits_between/3` — a
generation-pruned structural walk, immune to committer-date skew. Ordering
exploits two commit-graph invariants: a commit's corrected commit date is
`>=` every ancestor's, and its topological generation number is strictly
greater. Sorting the range by `{corrected_commit_date, generation}`
**ascending** therefore always places parents before children — a valid
topological order, oldest-first, with no Kahn sort needed. Remaining ties
(necessarily unrelated commits) break by SHA for determinism.
Callers must ensure `graph` covers both SHAs;
`ExGitObjectstore.rev_list_range/4` does.
"""
@spec rev_list_range_graph(Repo.t(), Graph.t(), String.t(), String.t(), keyword()) ::
{:ok, [{String.t(), Commit.t()}]} | {:error, term()}
def rev_list_range_graph(%Repo{} = repo, %Graph{} = graph, base_sha, head_sha, opts) do
with {:ok, shas} <- Graph.commits_between(graph, base_sha, head_sha),
{:ok, commits} <- load_range_commits(repo, shas) do
ordered =
shas
|> Enum.sort_by(fn sha ->
{:ok, ccd} = Graph.corrected_commit_date(graph, sha)
{:ok, gen} = Graph.generation(graph, sha)
{ccd, gen, sha}
end)
|> Enum.map(&{&1, Map.fetch!(commits, &1)})
|> maybe_drop_merges(Keyword.get(opts, :exclude_merges, false))
{:ok, ordered}
end
end
defp maybe_drop_merges(commits, false), do: commits
defp maybe_drop_merges(commits, true),
do: Enum.reject(commits, fn {_sha, c} -> length(c.parents) >= 2 end)
# Load every in-range commit object, short-circuiting on the first read
# error so a missing/unreadable commit surfaces instead of silently
# truncating the range.
defp load_range_commits(repo, shas) do
Enum.reduce_while(shas, {:ok, %{}}, fn sha, {:ok, acc} ->
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} -> {:cont, {:ok, Map.put(acc, sha, commit)}}
{:error, reason} -> {:halt, {:error, {:unreadable_commit, sha, reason}}}
end
end)
end
# Topological order, oldest-first, via Kahn's algorithm over the in-range
# subgraph. The ready set (commits whose in-range parents are all emitted) is
# drained oldest-first by committer date, ties broken by SHA for a stable,
# deterministic order. Every parent is emitted before its children, so the
# result is safe to replay with `rebase_commits/4`.
defp topo_oldest_first(interesting, commits) do
# in_parents: sha -> [in-range parent shas]; children: sha -> [in-range child shas]
{in_parents, children} =
Enum.reduce(interesting, {%{}, %{}}, fn sha, {ip, ch} ->
parents = commits |> Map.fetch!(sha) |> Map.fetch!(:parents)
in_range = Enum.filter(parents, &MapSet.member?(interesting, &1))
ip = Map.put(ip, sha, in_range)
ch = Enum.reduce(in_range, ch, fn p, acc -> Map.update(acc, p, [sha], &[sha | &1]) end)
{ip, ch}
end)
indegree = Map.new(in_parents, fn {sha, parents} -> {sha, length(parents)} end)
ready =
indegree
|> Enum.filter(fn {_sha, deg} -> deg == 0 end)
|> Enum.map(fn {sha, _} -> sort_key(sha, commits) end)
|> Enum.sort()
kahn_drain(ready, children, indegree, commits, [])
end
defp kahn_drain([], _children, _indegree, _commits, acc), do: Enum.reverse(acc)
defp kahn_drain([{_ts, sha} | rest], children, indegree, commits, acc) do
{ready, indegree} =
children
|> Map.get(sha, [])
|> Enum.reduce({rest, indegree}, fn child, {r, deg} ->
new = Map.fetch!(deg, child) - 1
deg = Map.put(deg, child, new)
if new == 0, do: {[sort_key(child, commits) | r], deg}, else: {r, deg}
end)
|> then(fn {r, deg} -> {Enum.sort(r), deg} end)
kahn_drain(ready, children, indegree, commits, [{sha, Map.fetch!(commits, sha)} | acc])
end
# Ready-queue key: ascending committer date, then SHA — oldest-first, ties
# broken deterministically by SHA. The ready set holds only concurrent branch
# tips, so re-sorting it each step is cheap.
defp sort_key(sha, commits) do
ts = commits |> Map.fetch!(sha) |> Map.fetch!(:committer) |> parse_timestamp()
{ts, sha}
end
# -- Merge base walk (priority queue with reachability tracking) --
test/ex_git_objectstore/graph_integration_test.exs +78 −0
@@ -245,6 +245,84 @@
end
end
# Graph-path coverage for issue fangorn/anvil#337 (REQ-PR-051): once the
# commit-graph covers the SHAs, `rev_list_range` must be answered from
# generation numbers / corrected commit dates — immune to committer-date
# skew — and match the structural fallback's answer.
describe "rev_list_range routing" do
setup do
handler = "rev-list-range-test-#{:erlang.unique_integer([:positive])}"
test_pid = self()
:telemetry.attach(
handler,
[:ex_git_objectstore, :graph, :query, :stop],
fn _event, _measurements, metadata, _cfg ->
send(test_pid, {:graph_query_stop, metadata})
end,
nil
)
on_exit(fn -> :telemetry.detach(handler) end)
:ok
end
test "graph path stays topological under skewed committer dates", %{repo: repo} do
# G(100) <- M(150, skewed-high common ancestor) <- B(101) [base]
# \ <- N(120) <- H(130) [head]
# base..head = [N, H]; M and G must be excluded despite M's high date.
t = tree!(repo)
g = commit!(repo, t, [], 100)
m = commit!(repo, t, [g], 150)
b = commit!(repo, t, [m], 101)
n = commit!(repo, t, [m], 120)
h = commit!(repo, t, [n], 130)
:ok = ExGitObjectstore.create_branch(repo, "base", b)
:ok = ExGitObjectstore.create_branch(repo, "head", h)
:ok = ExGitObjectstore.rebuild_graph(repo)
{:ok, range} = ExGitObjectstore.rev_list_range(repo, b, h)
assert Enum.map(range, &elem(&1, 0)) == [n, h]
assert_receive {:graph_query_stop, %{path: :graph, operation: :rev_list_range}}, 1000
end
test "graph path orders a merge diamond oldest-first and honors exclude_merges",
%{repo: repo} do
# c1(100) <- c2(101) ----------\
# \ <- c3(102) <- c4(103) -- c5(104, merge) [head]
t = tree!(repo)
c1 = commit!(repo, t, [], 100)
c2 = commit!(repo, t, [c1], 101)
c3 = commit!(repo, t, [c1], 102)
c4 = commit!(repo, t, [c3], 103)
c5 = commit!(repo, t, [c2, c4], 104)
:ok = ExGitObjectstore.create_branch(repo, "main", c5)
:ok = ExGitObjectstore.rebuild_graph(repo)
{:ok, range} = ExGitObjectstore.rev_list_range(repo, c1, c5)
assert Enum.map(range, &elem(&1, 0)) == [c2, c3, c4, c5]
{:ok, no_merges} = ExGitObjectstore.rev_list_range(repo, c1, c5, exclude_merges: true)
assert Enum.map(no_merges, &elem(&1, 0)) == [c2, c3, c4]
assert_receive {:graph_query_stop, %{path: :graph, operation: :rev_list_range}}, 1000
end
test "falls back when head is not yet in the graph", %{repo: repo} do
t = tree!(repo)
base = commit!(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", base)
:ok = ExGitObjectstore.rebuild_graph(repo)
new_head = commit!(repo, t, [base], 2)
:ok = ExGitObjectstore.update_branch(repo, "main", new_head, base)
{:ok, range} = ExGitObjectstore.rev_list_range(repo, base, new_head)
assert Enum.map(range, &elem(&1, 0)) == [new_head]
assert_receive {:graph_query_stop, %{path: :fallback, operation: :rev_list_range}}, 1000
end
end
describe "rebuild_graph error path" do
test "returns {:error, _} if build fails" do
# Construct a repo whose tips reference commits that don't exist —
test/ex_git_objectstore/walk_test.exs +220 −0
@@ -285,6 +285,226 @@
end
end
describe "rev_list_range/4" do
# Diamond: base=c1; branch A (c2), branch B (c3->c4); merge c5.
#
# c1(100) <- c2(101) --------\
# \ c5(104, merge) = head
# <- c3(102) <- c4(103) --/
#
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("content\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = fn ts, parents, msg ->
c = %Commit{
tree: tree_sha,
parents: parents,
author: "A <a@a.com> #{ts} +0000",
committer: "A <a@a.com> #{ts} +0000",
message: msg
}
{:ok, sha} = Object.write(repo, c)
sha
end
c1 = commit.(100, [], "c1\n")
c2 = commit.(101, [c1], "c2\n")
c3 = commit.(102, [c1], "c3\n")
c4 = commit.(103, [c3], "c4\n")
c5 = commit.(104, [c2, c4], "c5 merge\n")
%{repo: repo, shas: %{c1: c1, c2: c2, c3: c3, c4: c4, c5: c5}}
end
test "empty range when base == head", %{repo: repo, shas: %{c5: c5}} do
assert {:ok, []} = ExGitObjectstore.rev_list_range(repo, c5, c5)
end
test "returns base..head oldest-first, parents before children",
%{repo: repo, shas: %{c1: c1, c2: c2, c3: c3, c4: c4, c5: c5}} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, c1, c5)
shas = Enum.map(commits, &elem(&1, 0))
# base c1 is excluded; c5 (child) comes last; every parent precedes child.
assert shas == [c2, c3, c4, c5]
end
test "exclude_merges drops the merge commit, keeps both sides",
%{repo: repo, shas: %{c1: c1, c2: c2, c3: c3, c4: c4, c5: c5}} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, c1, c5, exclude_merges: true)
shas = Enum.map(commits, &elem(&1, 0))
# c5 (merge) removed; the second-parent side (c3, c4) is NOT dropped.
assert shas == [c2, c3, c4]
refute c5 in shas
end
test "linear range excludes the base commit itself",
%{repo: repo, shas: %{c1: c1, c2: c2}} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, c1, c2)
assert Enum.map(commits, &elem(&1, 0)) == [c2]
end
end
# Regression for issue #337 — rebase merge of a PR whose head history
# contains merge commits (integration branch built by merging feature
# branches into main). REQ-PR-051.
describe "rev_list_range/4 + rebase_commits/4 (integration-branch shape)" do
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
who = %{name: "T", email: "t@x", when: ~U[2026-07-10 00:00:00Z]}
add_file = fn parents, path, content, msg ->
{:ok, blob} = Object.write(repo, Blob.from_content(content))
{:ok, base_entries} =
case parents do
[p | _] ->
{:ok, %Commit{tree: t}} = Object.read(repo, p)
{:ok, %Tree{entries: e}} = Object.read(repo, t)
{:ok, e}
[] ->
{:ok, []}
end
entries =
Enum.reject(base_entries, &(&1.name == path)) ++
[%{mode: "100644", name: path, sha: blob}]
{:ok, tree} = ExGitObjectstore.write_tree(repo, entries)
{:ok, sha} =
ExGitObjectstore.commit_tree(repo, tree, parents: parents, author: who, message: msg)
sha
end
{:ok, empty} = ExGitObjectstore.write_tree(repo, [])
{:ok, base} = ExGitObjectstore.commit_tree(repo, empty, author: who, message: "base")
feat_a = add_file.([base], "a.txt", "A", "add a")
feat_b = add_file.([base], "b.txt", "B", "add b")
{:ok, tree_a} = ExGitObjectstore.merge_commits(repo, base, feat_a)
{:ok, m1} =
ExGitObjectstore.commit_tree(repo, tree_a,
parents: [base, feat_a],
author: who,
message: "merge a"
)
{:ok, tree_b} = ExGitObjectstore.merge_commits(repo, m1, feat_b)
{:ok, m2} =
ExGitObjectstore.commit_tree(repo, tree_b,
parents: [m1, feat_b],
author: who,
message: "merge b"
)
%{repo: repo, base: base, head: m2, feat_a: feat_a, feat_b: feat_b, who: who}
end
test "replaying the non-merge range onto base succeeds and keeps all content",
%{repo: repo, base: base, head: head, feat_a: feat_a, feat_b: feat_b, who: who} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, base, head, exclude_merges: true)
shas = Enum.map(commits, &elem(&1, 0))
# Only the two content-bearing feature commits (the two merge commits are
# dropped). feat_a/feat_b are independent with equal timestamps, so their
# relative order is a deterministic-but-arbitrary tiebreak — assert the
# set, not a brittle sequence.
assert Enum.sort(shas) == Enum.sort([feat_a, feat_b])
# The whole point: replay no longer hits :merge_commit_needs_mainline.
assert {:ok, new_tip} = ExGitObjectstore.rebase_commits(repo, shas, base, committer: who)
{:ok, %Commit{tree: tree}} = Object.read(repo, new_tip)
{:ok, %Tree{entries: entries}} = Object.read(repo, tree)
names = entries |> Enum.map(& &1.name) |> Enum.sort()
assert names == ["a.txt", "b.txt"]
end
end
# Hardening for issue #337 (REQ-PR-051): the range walk must stay correct
# when committer timestamps are NOT monotonic with topology — routine in real
# repos (rebase/cherry-pick reset the committer date, amends, imported history).
# An ancestor of `base` must never leak into `base..head`, regardless of its
# committer timestamp.
describe "rev_list_range/4 — non-monotonic committer timestamps" do
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{:ok, blob_sha} = Object.write(repo, Blob.from_content("x\n"))
{:ok, tree} = Object.write(repo, Tree.new([%{mode: "100644", name: "f", sha: blob_sha}]))
%{repo: repo, tree: tree}
end
# Commit with an explicit committer timestamp to construct skewed histories.
defp commit_at(repo, tree, parents, ts, msg) do
c = %Commit{
tree: tree,
parents: parents,
author: "T <t@t> #{ts} +0000",
committer: "T <t@t> #{ts} +0000",
message: msg
}
{:ok, sha} = Object.write(repo, c)
sha
end
test "excludes an ancestor of base whose committer date is skewed high",
%{repo: repo, tree: tree} do
# X is the common root but carries a HIGHER committer date (200) than its
# descendants; it is an ancestor of base and must NOT appear in base..head.
# X(200) <- B(101) [base]; X(200) <- H(105) [head]
x = commit_at(repo, tree, [], 200, "X skewed-high root\n")
b = commit_at(repo, tree, [x], 101, "B base\n")
h = commit_at(repo, tree, [x], 105, "H head\n")
{:ok, range} = ExGitObjectstore.rev_list_range(repo, b, h)
assert Enum.map(range, &elem(&1, 0)) == [h]
end
test "excludes a common ancestor with a skewed-high committer date",
%{repo: repo, tree: tree} do
# G(100) <- M(150, skewed) <- B(101) [base]
# \ <- N(120) <- H(130) [head]
# base..head = {N, H}; M and G are common ancestors and must be excluded.
g = commit_at(repo, tree, [], 100, "G root\n")
m = commit_at(repo, tree, [g], 150, "M skewed-high common ancestor\n")
b = commit_at(repo, tree, [m], 101, "B base\n")
n = commit_at(repo, tree, [m], 120, "N\n")
h = commit_at(repo, tree, [n], 130, "H head\n")
{:ok, range} = ExGitObjectstore.rev_list_range(repo, b, h)
assert Enum.map(range, &elem(&1, 0)) == [n, h]
end
test "returns an error instead of silently truncating on a missing object",
%{repo: repo, tree: tree} do
# H's parent SHA was never written. The walk must surface an error, not
# silently drop the unreadable parent and report a clean {:ok, [H]}.
u = commit_at(repo, tree, [], 100, "U base\n")
missing = String.duplicate("0", 40)
h = commit_at(repo, tree, [missing], 105, "H head with dangling parent\n")
assert {:error, _} = ExGitObjectstore.rev_list_range(repo, u, h)
end
end
describe "commit" do
test "get commit by ref", %{repo: repo, shas: shas} do
{:ok, {sha, commit}} = ExGitObjectstore.commit(repo, "main")