@@ -180,6 +180,41 @@
end
@doc """
Like `ahead_behind/3`, but takes one base and many heads. Walks
`ancestors(base)` once and reuses it across every head — turns the
cost from `O(N · |ancestors(base)|)` into
`O(|ancestors(base)| + Σ head-only walks)`.
Returns `{:ok, %{head_sha => %{ahead: N, behind: M}}}` containing one
entry per head that is in the graph. Heads missing from the graph are
omitted; the caller (typically a wrapper that knows about ref-walker
fallbacks) is responsible for filling them in. Returns
`{:error, :missing_commit}` if `base_sha` is not in the graph.
"""
@spec ahead_behind_many(t(), sha(), [sha()]) ::
{:ok, %{sha() => %{ahead: non_neg_integer(), behind: non_neg_integer()}}}
| {:error, :missing_commit}
def ahead_behind_many(%__MODULE__{by_sha: by_sha}, base_sha, head_shas)
when is_list(head_shas) do
with {:ok, _} <- fetch_entry(by_sha, base_sha) do
base_ancestors = collect_ancestors(by_sha, [base_sha], %{})
base_size = map_size(base_ancestors)
result =
Enum.reduce(head_shas, %{}, fn head_sha, acc ->
if Map.has_key?(by_sha, head_sha) do
counts = ahead_behind_against(by_sha, base_ancestors, base_size, head_sha)
Map.put(acc, head_sha, counts)
else
acc
end
end)
{:ok, result}
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`.
@@ -307,6 +342,83 @@
[item | list]
else
[head | insert_by_gen(rest, item)]
end
end
# -- ahead_behind_many helpers ---------------------------------------
# Collect every ancestor of the seed SHAs (inclusive) into a plain map
# used as a set. Order doesn't matter — membership is the only query.
defp collect_ancestors(_by, [], acc), do: acc
defp collect_ancestors(by, [sha | rest], acc) do
if Map.has_key?(acc, sha) do
collect_ancestors(by, rest, acc)
else
%{parents: parents} = Map.fetch!(by, sha)
collect_ancestors(by, parents ++ rest, Map.put(acc, sha, true))
end
end
defp ahead_behind_against(by_sha, base_ancestors, base_size, head_sha) do
{ahead_count, merge_points} =
walk_head(by_sha, base_ancestors, [head_sha], %{}, 0, [])
intersect_size = collect_within(by_sha, base_ancestors, merge_points, %{}, 0)
%{ahead: ahead_count, behind: base_size - intersect_size}
end
# Walk from `head_sha` toward roots. A commit in `base_ancestors` is a
# merge point — record it and stop pushing parents from this branch
# (everything reachable through it is also in `base_ancestors`, so it
# contributes to the intersection, not to ahead). A commit not in
# `base_ancestors` is a head-only commit — bump the ahead count and
# push its parents.
defp walk_head(_by, _base, [], _seen, ahead_count, merge_points),
do: {ahead_count, merge_points}
defp walk_head(by, base, [sha | rest], seen, ahead_count, merge_points) do
cond do
Map.has_key?(seen, sha) ->
walk_head(by, base, rest, seen, ahead_count, merge_points)
Map.has_key?(base, sha) ->
walk_head(by, base, rest, Map.put(seen, sha, true), ahead_count, [sha | merge_points])
true ->
%{parents: parents} = Map.fetch!(by, sha)
walk_head(
by,
base,
parents ++ rest,
Map.put(seen, sha, true),
ahead_count + 1,
merge_points
)
end
end
# BFS DOWN from `merge_points`, restricted to commits within
# `base_ancestors`. Returns the count of distinct commits visited —
# this is `|ancestors(head) ∩ ancestors(base)|`. Behind is then
# `|ancestors(base)| - intersection`.
defp collect_within(_by, _base, [], _seen, count), do: count
defp collect_within(by, base, [sha | rest], seen, count) do
cond do
Map.has_key?(seen, sha) ->
collect_within(by, base, rest, seen, count)
not Map.has_key?(base, sha) ->
# Defensive: caller seeds with merge points that are by
# construction in `base`, so this branch shouldn't fire.
collect_within(by, base, rest, seen, count)
true ->
%{parents: parents} = Map.fetch!(by, sha)
collect_within(by, base, parents ++ rest, Map.put(seen, sha, true), count + 1)
end
end
end