ref:802e450cbe1231dc1a685c44fd51e04cbb9e9ceb

feat: cursor-based pagination for commit log (log_page/log_continue)

Each page is O(page_size) regardless of depth — no re-walking from HEAD. The cursor serializes the walk frontier (queue SHAs) so continuation reconstructs the priority queue exactly where the previous page left off. Works correctly across merge histories where single-SHA cursors fail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 802e450cbe1231dc1a685c44fd51e04cbb9e9ceb
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-26 21:57
Parents: 33706a5
3 files changed +163 -0
Type
lib/ex_git_objectstore.ex +21 −0
@@ -156,6 +156,27 @@
end
@doc """
Paginated log returning `{:ok, commits, cursor}`. Pass cursor to
`log_continue/3` for the next page. Each page is O(page_size).
"""
@spec log_page(Repo.t(), sha() | ref_name(), keyword()) ::
{:ok, [{sha(), Commit.t()}], [sha()]} | {:error, term()}
def log_page(%Repo{} = repo, ref_or_sha, opts \\ []) do
with {:ok, sha} <- resolve(repo, ref_or_sha) do
Walk.log_page(repo, sha, opts)
end
end
@doc """
Continue a paginated log from a cursor. Returns `{:ok, [], []}` when done.
"""
@spec log_continue(Repo.t(), [sha()], keyword()) ::
{:ok, [{sha(), Commit.t()}], [sha()]} | {:error, term()}
def log_continue(%Repo{} = repo, cursor, opts \\ []) do
Walk.log_continue(repo, cursor, opts)
end
@doc """
Get a tree listing at a given path for a ref or SHA.
Path "/" returns the root tree.
"""
lib/ex_git_objectstore/walk.ex +89 −0
@@ -68,6 +68,59 @@
end
end
@doc """
Paginated walk that returns a cursor for efficient continuation.
Returns `{:ok, commits, cursor}` where `cursor` is a list of SHAs
representing the walk frontier. Pass this cursor to `log_continue/3`
for the next page. An empty cursor means no more commits.
Each page is O(page_size) regardless of depth — no re-walking from HEAD.
## Options
* `:max_count` — page size (default: 20)
"""
@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)
case seed_queue(repo, start_sha) do
{:ok, queue, seen} ->
{commits, remaining} = walk_page(repo, queue, seen, [], 0, max_count)
{:ok, commits, queue_to_cursor(remaining)}
{:error, _} = err ->
err
end
end
@doc """
Continue a paginated walk from a cursor returned by `log_page/3` or
a previous `log_continue/3` call.
Returns `{:ok, [], []}` if the cursor is empty (no more commits).
## Options
* `:max_count` — page size (default: 20)
"""
@spec log_continue(Repo.t(), [String.t()], keyword()) ::
{:ok, [{String.t(), Commit.t()}], [String.t()]} | {:error, term()}
def log_continue(%Repo{} = _repo, [], _opts), do: {:ok, [], []}
def log_continue(%Repo{} = repo, cursor_shas, opts) when is_list(cursor_shas) do
max_count = Keyword.get(opts, :max_count, 20)
case cursor_to_queue(repo, cursor_shas) do
{:ok, queue, seen} ->
{commits, remaining} = walk_page(repo, queue, seen, [], 0, max_count)
{:ok, commits, queue_to_cursor(remaining)}
{:error, _} = err ->
err
end
end
defp apply_skip_and_limit(commits, 0, :infinity), do: commits
defp apply_skip_and_limit(commits, skip, max_count) do
@@ -94,6 +147,27 @@
err
end
end
# Rebuild a walk queue from serialized cursor SHAs.
defp cursor_to_queue(repo, shas) do
{queue, seen} =
Enum.reduce(shas, {[], MapSet.new()}, fn sha, {q, s} ->
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
ts = parse_timestamp(commit.committer)
{insert_sorted(q, {ts, sha, commit}), MapSet.put(s, sha)}
_ ->
{q, s}
end
end)
{:ok, queue, seen}
end
defp queue_to_cursor(queue) do
Enum.map(queue, fn {_ts, sha, _commit} -> sha end)
end
@doc """
Find the merge base (lowest common ancestor) of two commits.
@@ -156,6 +230,21 @@
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)
end
# Like walk/6 but returns {commits, remaining_queue} for cursor pagination.
defp walk_page(_repo, [], _seen, acc, _count, _limit) do
{Enum.reverse(acc), []}
end
defp walk_page(_repo, queue, _seen, acc, count, limit)
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) do
{queue, seen} = enqueue_parents(repo, commit.parents, rest, seen)
walk_page(repo, queue, seen, [{sha, commit} | acc], count + 1, limit)
end
defp enqueue_parents(_repo, [], queue, seen), do: {queue, seen}
test/ex_git_objectstore/walk_test.exs +53 −0
@@ -120,6 +120,40 @@
{:ok, log} = ExGitObjectstore.log(repo, "main", max_count: 2, skip: 1)
assert length(log) == 2
end
test "log_page returns commits and cursor", %{repo: repo, shas: shas} do
{:ok, page, cursor} = ExGitObjectstore.log_page(repo, "main", max_count: 2)
page_shas = Enum.map(page, &elem(&1, 0))
# First 2 of [c5, c4, c3, c2, c1]
assert page_shas == [Enum.at(shas, 4), Enum.at(shas, 3)]
assert is_list(cursor)
assert cursor != []
end
test "log_continue resumes from cursor", %{repo: repo} do
{:ok, all} = ExGitObjectstore.log(repo, "main")
all_shas = Enum.map(all, &elem(&1, 0))
# Page through 2-at-a-time using cursor
{:ok, p1, cursor1} = ExGitObjectstore.log_page(repo, "main", max_count: 2)
{:ok, p2, cursor2} = ExGitObjectstore.log_continue(repo, cursor1, max_count: 2)
{:ok, p3, cursor3} = ExGitObjectstore.log_continue(repo, cursor2, max_count: 2)
paged_shas =
Enum.map(p1, &elem(&1, 0)) ++
Enum.map(p2, &elem(&1, 0)) ++
Enum.map(p3, &elem(&1, 0))
assert paged_shas == all_shas
# Last page had only 1 commit, cursor should be empty
assert cursor3 == []
end
test "log_continue with empty cursor returns empty", %{repo: repo} do
{:ok, commits, cursor} = ExGitObjectstore.log_continue(repo, [], max_count: 20)
assert commits == []
assert cursor == []
end
end
describe "log with merge commits" do
@@ -229,5 +263,24 @@
# The 3 most recent by timestamp: C5, C4, C3
assert log_shas == [shas.c5, shas.c4, shas.c3]
end
test "cursor pagination works across merge histories", %{repo: repo, shas: shas} do
{:ok, all} = ExGitObjectstore.log(repo, "main")
all_shas = Enum.map(all, &elem(&1, 0))
assert all_shas == [shas.c5, shas.c4, shas.c3, shas.c2, shas.c1]
# Page through 2-at-a-time using cursor — must match full result
{:ok, p1, cursor1} = ExGitObjectstore.log_page(repo, "main", max_count: 2)
{:ok, p2, cursor2} = ExGitObjectstore.log_continue(repo, cursor1, max_count: 2)
{:ok, p3, cursor3} = ExGitObjectstore.log_continue(repo, cursor2, max_count: 2)
paged_shas =
Enum.map(p1, &elem(&1, 0)) ++
Enum.map(p2, &elem(&1, 0)) ++
Enum.map(p3, &elem(&1, 0))
assert paged_shas == all_shas
assert cursor3 == []
end
end