@@ -52,6 +52,8 @@
def log(%Repo{} = repo, start_sha, opts \\ []) do
max_count = Keyword.get(opts, :max_count, :infinity)
skip = Keyword.get(opts, :skip, 0)
path = Keyword.get(opts, :path)
path_components = parse_path_filter(path)
emit_limit =
case max_count do
@@ -61,7 +63,7 @@
case seed_queue(repo, start_sha) do
{:ok, queue, seen} ->
{:ok, commits} = walk(repo, queue, seen, [], 0, emit_limit)
{:ok, commits} = walk(repo, queue, seen, [], 0, emit_limit, path_components, %{})
{:ok, apply_skip_and_limit(commits, skip, max_count)}
{:error, _} = err ->
@@ -80,15 +82,20 @@
## Options
* `:max_count` — page size (default: 20)
* `:path` — filter commits that modified the given file or directory path
"""
@spec log_page(Repo.t(), String.t(), keyword()) ::
{:ok, [{String.t(), Commit.t()}], [String.t()]} | {:error, term()}
def log_page(%Repo{} = repo, start_sha, opts \\ []) do
max_count = Keyword.get(opts, :max_count, 20)
path = Keyword.get(opts, :path)
path_components = parse_path_filter(path)
case seed_queue(repo, start_sha) do
{:ok, queue, seen} ->
{commits, remaining} = walk_page(repo, queue, seen, [], 0, max_count)
{commits, remaining} =
walk_page(repo, queue, seen, [], 0, max_count, path_components, %{})
{:ok, commits, queue_to_cursor(remaining)}
{:error, _} = err ->
@@ -104,6 +111,7 @@
## Options
* `:max_count` — page size (default: 20)
* `:path` — filter commits that modified the given file or directory path
"""
@spec log_continue(Repo.t(), [String.t()], keyword()) ::
{:ok, [{String.t(), Commit.t()}], [String.t()]} | {:error, term()}
@@ -111,12 +119,19 @@
def log_continue(%Repo{} = repo, cursor_shas, opts) when is_list(cursor_shas) do
max_count = Keyword.get(opts, :max_count, 20)
path = Keyword.get(opts, :path)
path_components = parse_path_filter(path)
{:ok, queue, seen} = cursor_to_queue(repo, cursor_shas)
{commits, remaining} = walk_page(repo, queue, seen, [], 0, max_count)
{commits, remaining} = walk_page(repo, queue, seen, [], 0, max_count, path_components, %{})
{:ok, commits, queue_to_cursor(remaining)}
end
defp parse_path_filter(nil), do: nil
defp parse_path_filter(""), do: nil
defp parse_path_filter("/"), do: nil
defp parse_path_filter(path) when is_binary(path), do: String.split(path, "/", trim: true)
defp apply_skip_and_limit(commits, 0, :infinity), do: commits
defp apply_skip_and_limit(commits, skip, max_count) do
@@ -187,11 +202,12 @@
ts_a = parse_timestamp(ca.committer)
ts_b = parse_timestamp(cb.committer)
queue = [{ts_a, sha_a}, {ts_b, sha_b}]
queue = [{ts_a, sha_a, ca}, {ts_b, sha_b, cb}]
queue = Enum.sort_by(queue, &elem(&1, 0), :desc)
reachable = %{sha_a => :a, sha_b => :b}
cache = %{sha_a => ca, sha_b => cb}
merge_base_walk(repo, queue, reachable)
merge_base_walk(repo, queue, reachable, cache)
{{:error, _} = err, _} ->
err
@@ -214,57 +230,197 @@
# The queue is a descending-sorted list — insert_sorted maintains the
# invariant, equivalent to git's commit_list_insert_by_date().
defp walk(_repo, [], _seen, acc, _count, _limit) do
defp walk(_repo, [], _seen, acc, _count, _limit, _path_comps, _cache) do
{:ok, Enum.reverse(acc)}
end
defp walk(_repo, _queue, _seen, acc, count, limit)
defp walk(_repo, _queue, _seen, acc, count, limit, _path_comps, _cache)
when is_integer(limit) and count >= limit do
{:ok, Enum.reverse(acc)}
end
defp walk(repo, [{_ts, sha, commit} | rest], seen, acc, count, limit) do
{queue, seen} = enqueue_parents(repo, commit.parents, rest, seen)
walk(repo, queue, seen, [{sha, commit} | acc], count + 1, limit)
defp walk(repo, [{_ts, sha, commit} | rest], seen, acc, count, limit, nil, cache) do
{queue, seen, cache} = enqueue_parents(repo, commit.parents, rest, seen, cache)
walk(repo, queue, seen, [{sha, commit} | acc], count + 1, limit, nil, cache)
end
# Like walk/6 but returns {commits, remaining_queue} for cursor pagination.
defp walk_page(_repo, [], _seen, acc, _count, _limit) do
defp walk(repo, [{_ts, sha, commit} | rest], seen, acc, count, limit, path_comps, cache) do
cache = Map.put(cache, sha, commit)
{changed?, parents_to_follow, cache} = check_path_change(repo, commit, path_comps, cache)
{queue, seen, cache} = enqueue_parents(repo, parents_to_follow, rest, seen, cache)
if changed? do
walk(repo, queue, seen, [{sha, commit} | acc], count + 1, limit, path_comps, cache)
else
walk(repo, queue, seen, acc, count, limit, path_comps, cache)
end
end
# Like walk/8 but returns {commits, remaining_queue} for cursor pagination.
defp walk_page(_repo, [], _seen, acc, _count, _limit, _path_comps, _cache) do
{Enum.reverse(acc), []}
end
defp walk_page(_repo, queue, _seen, acc, count, limit)
defp walk_page(_repo, queue, _seen, acc, count, limit, _path_comps, _cache)
when is_integer(limit) and count >= limit do
{Enum.reverse(acc), queue}
end
defp walk_page(repo, [{_ts, sha, commit} | rest], seen, acc, count, limit, nil, cache) do
{queue, seen, cache} = enqueue_parents(repo, commit.parents, rest, seen, cache)
walk_page(repo, queue, seen, [{sha, commit} | acc], count + 1, limit, nil, cache)
end
defp walk_page(repo, [{_ts, sha, commit} | rest], seen, acc, count, limit, path_comps, cache) do
cache = Map.put(cache, sha, commit)
{changed?, parents_to_follow, cache} = check_path_change(repo, commit, path_comps, cache)
{queue, seen, cache} = enqueue_parents(repo, parents_to_follow, rest, seen, cache)
defp walk_page(repo, [{_ts, sha, commit} | rest], seen, acc, count, limit) do
{queue, seen} = enqueue_parents(repo, commit.parents, rest, seen)
walk_page(repo, queue, seen, [{sha, commit} | acc], count + 1, limit)
if changed? do
walk_page(repo, queue, seen, [{sha, commit} | acc], count + 1, limit, path_comps, cache)
else
walk_page(repo, queue, seen, acc, count, limit, path_comps, cache)
end
end
defp enqueue_parents(_repo, [], queue, seen), do: {queue, seen}
defp enqueue_parents(_repo, [], queue, seen, cache), do: {queue, seen, cache}
defp enqueue_parents(repo, [parent_sha | rest_parents], queue, seen) do
defp enqueue_parents(repo, [parent_sha | rest_parents], queue, seen, cache) do
if MapSet.member?(seen, parent_sha) do
enqueue_parents(repo, rest_parents, queue, seen)
enqueue_parents(repo, rest_parents, queue, seen, cache)
else
{queue, seen} = enqueue_one_parent(repo, parent_sha, queue, seen)
enqueue_parents(repo, rest_parents, queue, seen)
{queue, seen, cache} = enqueue_one_parent(repo, parent_sha, queue, seen, cache)
enqueue_parents(repo, rest_parents, queue, seen, cache)
end
end
defp enqueue_one_parent(repo, sha, queue, seen) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
defp enqueue_one_parent(repo, sha, queue, seen, cache) do
case get_cached_commit(repo, sha, cache) do
{%Commit{} = commit, cache} ->
ts = parse_timestamp(commit.committer)
{insert_sorted(queue, {ts, sha, commit}), MapSet.put(seen, sha)}
{insert_sorted(queue, {ts, sha, commit}), MapSet.put(seen, sha), cache}
_ ->
{queue, seen}
{queue, seen, cache}
end
end
# History simplification with TREESAME check for path filtering
defp check_path_change(repo, commit, path_comps, cache) do
case commit.parents do
[] ->
{entry, cache} = get_tree_path_entry(repo, commit.tree, path_comps, cache)
{entry != nil, [], cache}
[parent_sha] ->
{parent_commit, cache} = get_cached_commit(repo, parent_sha, cache)
if parent_commit && commit.tree == parent_commit.tree do
{false, [parent_sha], cache}
else
{my_entry, cache} = get_tree_path_entry(repo, commit.tree, path_comps, cache)
{p_entry, cache} =
if parent_commit do
get_tree_path_entry(repo, parent_commit.tree, path_comps, cache)
else
{nil, cache}
end
changed? = my_entry != p_entry
{changed?, [parent_sha], cache}
end
parents ->
{my_entry, cache} = get_tree_path_entry(repo, commit.tree, path_comps, cache)
{same_parent, cache} =
Enum.reduce_while(parents, {nil, cache}, fn p_sha, {_found, acc_cache} ->
{p_commit, acc_cache} = get_cached_commit(repo, p_sha, acc_cache)
if p_commit && commit.tree == p_commit.tree do
{:halt, {p_sha, acc_cache}}
else
{p_entry, acc_cache} =
if p_commit do
get_tree_path_entry(repo, p_commit.tree, path_comps, acc_cache)
else
{nil, acc_cache}
end
if my_entry == p_entry do
{:halt, {p_sha, acc_cache}}
else
{:cont, {nil, acc_cache}}
end
end
end)
case same_parent do
nil ->
{true, parents, cache}
treesame_p_sha ->
{false, [treesame_p_sha], cache}
end
end
end
defp get_tree_path_entry(_repo, nil, _components, cache), do: {nil, cache}
defp get_tree_path_entry(_repo, _tree_sha, [], cache), do: {nil, cache}
defp get_tree_path_entry(repo, tree_sha, [comp | rest], cache) do
key = {:path_entry, tree_sha, [comp | rest]}
case cache do
%{^key => entry} ->
{entry, cache}
_ ->
case ObjectResolver.read(repo, tree_sha) do
{:ok, %ExGitObjectstore.Object.Tree{entries: entries}} ->
case Enum.find(entries, &(&1.name == comp)) do
nil ->
{nil, Map.put(cache, key, nil)}
%{mode: "40000", sha: sub_sha} ->
if rest == [] do
entry = %{mode: "40000", sha: sub_sha, name: comp}
{entry, Map.put(cache, key, entry)}
else
{entry, cache} = get_tree_path_entry(repo, sub_sha, rest, cache)
{entry, Map.put(cache, key, entry)}
end
leaf_entry when rest == [] ->
{leaf_entry, Map.put(cache, key, leaf_entry)}
_ ->
{nil, Map.put(cache, key, nil)}
end
_ ->
{nil, Map.put(cache, key, nil)}
end
end
end
defp get_cached_commit(repo, sha, cache) do
case cache do
%{^sha => %Commit{} = commit} ->
{commit, cache}
_ ->
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
{commit, Map.put(cache, sha, commit)}
_ ->
{nil, cache}
end
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
@@ -416,50 +572,48 @@
# -- Merge base walk (priority queue with reachability tracking) --
defp merge_base_walk(_repo, [], _reachable), do: {:error, :no_merge_base}
defp merge_base_walk(_repo, [], _reachable, _cache), do: {:error, :no_merge_base}
defp merge_base_walk(repo, [{_ts, sha} | rest], reachable) do
defp merge_base_walk(repo, [{_ts, sha, commit} | rest], reachable, cache) do
case Map.get(reachable, sha) do
:both ->
{:ok, sha}
side ->
process_merge_base_commit(repo, sha, side, rest, reachable)
end
end
defp process_merge_base_commit(repo, sha, side, rest, reachable) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
{new_queue, new_reachable} =
{new_queue, new_reachable, new_cache} =
Enum.reduce(commit.parents, {rest, reachable, cache}, fn parent, {q, r, c} ->
enqueue_merge_parent(repo, parent, side, q, r, c)
Enum.reduce(commit.parents, {rest, reachable}, fn parent, {q, r} ->
enqueue_merge_parent(repo, parent, side, q, r)
end)
merge_base_walk(repo, new_queue, new_reachable)
{:error, _} = err ->
err
merge_base_walk(repo, new_queue, new_reachable, new_cache)
end
end
defp enqueue_merge_parent(repo, parent, side, queue, reachable) do
defp enqueue_merge_parent(repo, parent, side, queue, reachable, cache) do
existing = Map.get(reachable, parent)
new_side = compute_new_side(existing, side)
reachable = Map.put(reachable, parent, new_side)
needs_enqueue = existing == nil or (existing != new_side and new_side == :both)
if needs_enqueue do
case ObjectResolver.read(repo, parent) do
{:ok, %Commit{} = pc} ->
case Map.get(cache, parent) do
%Commit{} = pc ->
ts = parse_timestamp(pc.committer)
queue = insert_sorted(queue, {ts, parent, pc})
{queue, reachable, cache}
queue = insert_sorted(queue, {ts, parent})
{queue, reachable}
nil ->
case ObjectResolver.read(repo, parent) do
{:ok, %Commit{} = pc} ->
ts = parse_timestamp(pc.committer)
queue = insert_sorted(queue, {ts, parent, pc})
{queue, reachable, Map.put(cache, parent, pc)}
_ ->
_ ->
{queue, reachable, cache}
end
{queue, reachable}
end
else
{queue, reachable, cache}
{queue, reachable}
end
end