ref:c48d20ea31d7aed60c8afe0effb2c35d2908aa0c

Phase 4: Read/Write API + ETS cache

- Commit graph traversal (Walk.log with max_count/skip) - Merge base finding (LCA in commit DAG) - ETS-based LRU cache with eviction - High-level API: commit/2, log/3, tree/3, blob/2, blob_size/2 - Tree path traversal for nested directories - 112 tests passing Refs: notifd/anvil#62 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: c48d20ea31d7aed60c8afe0effb2c35d2908aa0c
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-10 07:44
Parents: 4f88147
5 files changed +611 -1
Type
lib/ex_git_objectstore.ex +93 −1
@@ -6,7 +6,8 @@
All git data (objects, refs, packs) is stored via a pluggable storage backend.
"""
alias ExGitObjectstore.{Object, ObjectResolver, Repo, Ref}
alias ExGitObjectstore.{Object, ObjectResolver, Repo, Ref, Walk}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
@type sha :: String.t()
@type ref_name :: String.t()
@@ -112,5 +113,96 @@
@spec delete_tag(Repo.t(), String.t()) :: :ok | {:error, term()}
def delete_tag(%Repo{} = repo, name) do
Ref.delete(repo, "refs/tags/#{name}")
end
@doc """
Get a commit by SHA or ref.
"""
@spec commit(Repo.t(), sha() | ref_name()) :: {:ok, {sha(), Commit.t()}} | {:error, term()}
def commit(%Repo{} = repo, ref_or_sha) do
with {:ok, sha} <- resolve(repo, ref_or_sha),
{:ok, %Commit{} = commit} <- cat_object(repo, sha) do
{:ok, {sha, commit}}
end
end
@doc """
Get the commit log starting from a ref or SHA.
## Options
* `:max_count` — maximum number of commits (default: all)
* `:skip` — skip N commits (default: 0)
"""
@spec log(Repo.t(), sha() | ref_name(), keyword()) ::
{:ok, [{sha(), Commit.t()}]} | {:error, term()}
def log(%Repo{} = repo, ref_or_sha, opts \\ []) do
with {:ok, sha} <- resolve(repo, ref_or_sha) do
Walk.log(repo, sha, opts)
end
end
@doc """
Get a tree listing at a given path for a ref or SHA.
Path "/" returns the root tree.
"""
@spec tree(Repo.t(), sha() | ref_name(), String.t()) ::
{:ok, Tree.t()} | {:error, term()}
def tree(%Repo{} = repo, ref_or_sha, path \\ "/") do
with {:ok, sha} <- resolve(repo, ref_or_sha),
{:ok, %Commit{} = commit} <- cat_object(repo, sha),
{:ok, %Tree{} = root_tree} <- cat_object(repo, commit.tree) do
if path == "/" or path == "" do
{:ok, root_tree}
else
walk_tree_path(repo, root_tree, String.split(path, "/", trim: true))
end
end
end
@doc """
Get blob content by SHA.
"""
@spec blob(Repo.t(), sha()) :: {:ok, binary()} | {:error, term()}
def blob(%Repo{} = repo, sha) do
case cat_object(repo, sha) do
{:ok, %Blob{content: content}} -> {:ok, content}
{:ok, _other} -> {:error, :not_a_blob}
{:error, _} = err -> err
end
end
@doc """
Get the size of a blob by SHA without reading full content.
Currently reads the full blob — could be optimized with pack header parsing.
"""
@spec blob_size(Repo.t(), sha()) :: {:ok, non_neg_integer()} | {:error, term()}
def blob_size(%Repo{} = repo, sha) do
case blob(repo, sha) do
{:ok, content} -> {:ok, byte_size(content)}
{:error, _} = err -> err
end
end
# Walk into nested tree directories by path components
defp walk_tree_path(_repo, tree, []), do: {:ok, tree}
defp walk_tree_path(repo, %Tree{entries: entries}, [component | rest]) do
case Enum.find(entries, &(&1.name == component)) do
nil ->
{:error, {:path_not_found, component}}
%{mode: "40000", sha: sha} ->
case cat_object(repo, sha) do
{:ok, %Tree{} = subtree} -> walk_tree_path(repo, subtree, rest)
{:error, _} = err -> err
end
%{sha: sha} when rest == [] ->
# Leaf entry — return the blob/tree
cat_object(repo, sha)
_ ->
{:error, {:not_a_directory, component}}
end
end
end
lib/ex_git_objectstore/cache/ets.ex +104 −0
@@ -1,0 +1,104 @@
defmodule ExGitObjectstore.Cache.ETS do
@moduledoc """
ETS-based LRU cache for git objects and pack indexes.
Objects are immutable by SHA, making them ideal for caching.
Cache keys are `{repo_id, sha}` tuples.
Config:
%{table: :my_cache_table, max_entries: 10_000}
The caller is responsible for creating the ETS table before use:
:ets.new(:my_cache, [:set, :public, :named_table])
"""
@doc """
Create a new ETS table for caching and return config.
"""
@spec new(atom(), keyword()) :: map()
def new(table_name \\ :ex_git_objectstore_cache, opts \\ []) do
max_entries = Keyword.get(opts, :max_entries, 10_000)
table =
:ets.new(table_name, [
:set,
:public,
read_concurrency: true,
write_concurrency: true
])
%{table: table, max_entries: max_entries}
end
@doc """
Get an object from the cache.
"""
@spec get(map(), String.t(), String.t()) :: {:ok, term()} | :miss
def get(%{table: table}, repo_id, key) do
case :ets.lookup(table, {repo_id, key}) do
[{_, value, _ts}] ->
# Update access time for LRU
:ets.update_element(table, {repo_id, key}, {3, System.monotonic_time()})
{:ok, value}
[] ->
:miss
end
end
@doc """
Put an object into the cache.
"""
@spec put(map(), String.t(), String.t(), term()) :: :ok
def put(%{table: table, max_entries: max_entries}, repo_id, key, value) do
# Check size and evict if needed
size = :ets.info(table, :size)
if size >= max_entries do
evict_oldest(table, div(max_entries, 10))
end
:ets.insert(table, {{repo_id, key}, value, System.monotonic_time()})
:ok
end
@doc """
Delete a specific entry from the cache.
"""
@spec delete(map(), String.t(), String.t()) :: :ok
def delete(%{table: table}, repo_id, key) do
:ets.delete(table, {repo_id, key})
:ok
end
@doc """
Clear all entries for a specific repo.
"""
@spec clear_repo(map(), String.t()) :: :ok
def clear_repo(%{table: table}, repo_id) do
:ets.match_delete(table, {{repo_id, :_}, :_, :_})
:ok
end
@doc """
Clear the entire cache.
"""
@spec clear(map()) :: :ok
def clear(%{table: table}) do
:ets.delete_all_objects(table)
:ok
end
# Evict the N oldest entries by access time
defp evict_oldest(table, count) do
# Get all entries sorted by access time
entries =
:ets.tab2list(table)
|> Enum.sort_by(fn {_key, _value, ts} -> ts end)
|> Enum.take(count)
for {key, _value, _ts} <- entries do
:ets.delete(table, key)
end
end
end
lib/ex_git_objectstore/walk.ex +159 −0
@@ -1,0 +1,159 @@
defmodule ExGitObjectstore.Walk do
@moduledoc """
Commit graph traversal — implements `git log` style iteration.
Supports topological + time ordering with configurable limits.
"""
alias ExGitObjectstore.{ObjectResolver, Repo}
alias ExGitObjectstore.Object.Commit
@type log_opts :: [
max_count: non_neg_integer(),
skip: non_neg_integer(),
since: DateTime.t() | nil,
until: DateTime.t() | nil,
path: String.t() | nil
]
@doc """
Walk the commit graph starting from the given SHA.
Returns commits in reverse chronological order (newest first).
## Options
* `:max_count` — maximum number of commits to return (default: all)
* `:skip` — number of commits to skip (default: 0)
"""
@spec log(Repo.t(), String.t(), log_opts()) ::
{:ok, [{String.t(), Commit.t()}]} | {:error, term()}
def log(%Repo{} = repo, start_sha, opts \\ []) do
max_count = Keyword.get(opts, :max_count, :infinity)
skip = Keyword.get(opts, :skip, 0)
limit =
case max_count do
:infinity -> :infinity
n -> n + skip
end
case walk(repo, [start_sha], MapSet.new(), [], limit) do
{:ok, all_commits} ->
result =
all_commits
|> Enum.drop(skip)
|> then(fn commits ->
case max_count do
:infinity -> commits
n -> Enum.take(commits, n)
end
end)
{:ok, result}
{:error, _} = err ->
err
end
end
@doc """
Find the merge base (lowest common ancestor) of two commits.
"""
@spec merge_base(Repo.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, term()}
def merge_base(%Repo{} = repo, sha_a, sha_b) do
# Build ancestor sets for both commits, find the first common ancestor
case {ancestor_set(repo, sha_a), ancestor_set(repo, sha_b)} do
{{:ok, set_a}, {:ok, set_b}} ->
# Walk from sha_a in order, find first commit that's also in set_b
common = MapSet.intersection(set_a, set_b)
if MapSet.size(common) == 0 do
{:error, :no_merge_base}
else
# Find the most recent common ancestor by walking from sha_a
find_first_common(repo, [sha_a], MapSet.new(), common)
end
{{:error, _} = err, _} ->
err
{_, {:error, _} = err} ->
err
end
end
# -- Private --
# BFS walk collecting commits in topological+time order
defp walk(_repo, [], _visited, acc, _limit), do: {:ok, sort_by_time(acc)}
defp walk(_repo, _queue, _visited, acc, limit)
when is_integer(limit) and length(acc) >= limit,
do: {:ok, sort_by_time(acc)}
defp walk(repo, [sha | rest], visited, acc, limit) do
if MapSet.member?(visited, sha) do
walk(repo, rest, visited, acc, limit)
else
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
visited = MapSet.put(visited, sha)
new_queue = rest ++ commit.parents
walk(repo, new_queue, visited, [{sha, commit} | acc], limit)
{:error, _} = err ->
err
end
end
end
defp sort_by_time(commits) do
Enum.sort_by(
commits,
fn {_sha, commit} ->
parse_timestamp(commit.committer)
end,
:desc
)
end
defp parse_timestamp(author_or_committer) do
# Format: "Name <email> timestamp timezone"
case Regex.run(~r/(\d+)\s+[+-]\d{4}$/, author_or_committer) do
[_, timestamp_str] -> String.to_integer(timestamp_str)
_ -> 0
end
end
defp ancestor_set(repo, sha) do
case walk(repo, [sha], MapSet.new(), [], 10_000) do
{:ok, commits} ->
set = commits |> Enum.map(&elem(&1, 0)) |> MapSet.new()
{:ok, set}
{:error, _} = err ->
err
end
end
defp find_first_common(_repo, [], _visited, _common), do: {:error, :no_merge_base}
defp find_first_common(repo, [sha | rest], visited, common) do
if MapSet.member?(visited, sha) do
find_first_common(repo, rest, visited, common)
else
if MapSet.member?(common, sha) do
{:ok, sha}
else
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
visited = MapSet.put(visited, sha)
new_queue = rest ++ commit.parents
find_first_common(repo, new_queue, visited, common)
{:error, _} = err ->
err
end
end
end
end
end
test/ex_git_objectstore/cache/ets_test.exs +78 −0
@@ -1,0 +1,78 @@
defmodule ExGitObjectstore.Cache.ETSTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Cache.ETS
setup do
config = ETS.new(:"cache_#{:erlang.unique_integer([:positive])}", max_entries: 10)
%{config: config}
end
describe "basic operations" do
test "get returns :miss for uncached key", %{config: config} do
assert :miss = ETS.get(config, "repo1", "abc123")
end
test "put and get", %{config: config} do
ETS.put(config, "repo1", "abc123", {:blob, "hello"})
assert {:ok, {:blob, "hello"}} = ETS.get(config, "repo1", "abc123")
end
test "different repos have separate namespaces", %{config: config} do
ETS.put(config, "repo1", "sha1", :value1)
ETS.put(config, "repo2", "sha1", :value2)
assert {:ok, :value1} = ETS.get(config, "repo1", "sha1")
assert {:ok, :value2} = ETS.get(config, "repo2", "sha1")
end
test "delete removes entry", %{config: config} do
ETS.put(config, "repo1", "sha1", :value)
ETS.delete(config, "repo1", "sha1")
assert :miss = ETS.get(config, "repo1", "sha1")
end
test "clear_repo removes all entries for a repo", %{config: config} do
ETS.put(config, "repo1", "sha1", :v1)
ETS.put(config, "repo1", "sha2", :v2)
ETS.put(config, "repo2", "sha1", :v3)
ETS.clear_repo(config, "repo1")
assert :miss = ETS.get(config, "repo1", "sha1")
assert :miss = ETS.get(config, "repo1", "sha2")
assert {:ok, :v3} = ETS.get(config, "repo2", "sha1")
end
test "clear removes everything", %{config: config} do
ETS.put(config, "repo1", "sha1", :v1)
ETS.put(config, "repo2", "sha2", :v2)
ETS.clear(config)
assert :miss = ETS.get(config, "repo1", "sha1")
assert :miss = ETS.get(config, "repo2", "sha2")
end
end
describe "eviction" do
test "evicts oldest entries when cache is full", %{config: config} do
# Config has max_entries: 10
# Fill it up
for i <- 1..10 do
ETS.put(config, "repo", "sha#{i}", i)
end
# Insert one more — should trigger eviction
ETS.put(config, "repo", "sha11", 11)
# The newest entry should still be there
assert {:ok, 11} = ETS.get(config, "repo", "sha11")
# Some of the oldest should be evicted
# (evicts 10% = 1 entry)
table_size = :ets.info(config.table, :size)
assert table_size <= 10
end
end
end
test/ex_git_objectstore/walk_test.exs +177 −0
@@ -1,0 +1,177 @@
defmodule ExGitObjectstore.WalkTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
# Build a chain of 5 commits
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 1 (oldest)
c1 = %Commit{
tree: tree_sha,
parents: [],
author: "A <a@a.com> 1000000000 +0000",
committer: "A <a@a.com> 1000000000 +0000",
message: "first\n"
}
{:ok, c1_sha} = Object.write(repo, c1)
# Commit 2
c2 = %Commit{
tree: tree_sha,
parents: [c1_sha],
author: "A <a@a.com> 1000000001 +0000",
committer: "A <a@a.com> 1000000001 +0000",
message: "second\n"
}
{:ok, c2_sha} = Object.write(repo, c2)
# Commit 3
c3 = %Commit{
tree: tree_sha,
parents: [c2_sha],
author: "A <a@a.com> 1000000002 +0000",
committer: "A <a@a.com> 1000000002 +0000",
message: "third\n"
}
{:ok, c3_sha} = Object.write(repo, c3)
# Commit 4
c4 = %Commit{
tree: tree_sha,
parents: [c3_sha],
author: "A <a@a.com> 1000000003 +0000",
committer: "A <a@a.com> 1000000003 +0000",
message: "fourth\n"
}
{:ok, c4_sha} = Object.write(repo, c4)
# Commit 5 (newest)
c5 = %Commit{
tree: tree_sha,
parents: [c4_sha],
author: "A <a@a.com> 1000000004 +0000",
committer: "A <a@a.com> 1000000004 +0000",
message: "fifth\n"
}
{:ok, c5_sha} = Object.write(repo, c5)
ExGitObjectstore.create_branch(repo, "main", c5_sha)
%{
repo: repo,
shas: [c1_sha, c2_sha, c3_sha, c4_sha, c5_sha],
tree_sha: tree_sha
}
end
describe "log" do
test "returns all commits in reverse chronological order", %{repo: repo, shas: shas} do
{:ok, log} = ExGitObjectstore.log(repo, "main")
log_shas = Enum.map(log, &elem(&1, 0))
assert length(log) == 5
# Newest first
assert List.first(log_shas) == List.last(shas)
assert List.last(log_shas) == List.first(shas)
end
test "max_count limits results", %{repo: repo} do
{:ok, log} = ExGitObjectstore.log(repo, "main", max_count: 3)
assert length(log) == 3
end
test "skip skips commits", %{repo: repo} do
{:ok, all} = ExGitObjectstore.log(repo, "main")
{:ok, skipped} = ExGitObjectstore.log(repo, "main", skip: 2)
assert length(skipped) == 3
assert Enum.map(skipped, &elem(&1, 0)) == Enum.map(all, &elem(&1, 0)) |> Enum.drop(2)
end
test "max_count and skip together", %{repo: repo} do
{:ok, log} = ExGitObjectstore.log(repo, "main", max_count: 2, skip: 1)
assert length(log) == 2
end
end
describe "commit" do
test "get commit by ref", %{repo: repo, shas: shas} do
{:ok, {sha, commit}} = ExGitObjectstore.commit(repo, "main")
assert sha == List.last(shas)
assert %Commit{message: "fifth\n"} = commit
end
test "get commit by SHA", %{repo: repo, shas: [first_sha | _]} do
{:ok, {sha, commit}} = ExGitObjectstore.commit(repo, first_sha)
assert sha == first_sha
assert commit.message == "first\n"
end
end
describe "tree" do
test "get root tree for commit", %{repo: repo} do
{:ok, tree} = ExGitObjectstore.tree(repo, "main")
assert %Tree{entries: [entry]} = tree
assert entry.name == "file.txt"
end
test "tree path traversal" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
# Create nested structure: src/main.ex
inner_blob = Blob.from_content("code\n")
{:ok, inner_sha} = Object.write(repo, inner_blob)
inner_tree = Tree.new([%{mode: "100644", name: "main.ex", sha: inner_sha}])
{:ok, inner_tree_sha} = Object.write(repo, inner_tree)
root_tree = Tree.new([%{mode: "40000", name: "src", sha: inner_tree_sha}])
{:ok, root_tree_sha} = Object.write(repo, root_tree)
commit = %Commit{
tree: root_tree_sha,
parents: [],
author: "A <a@a.com> 1000000000 +0000",
committer: "A <a@a.com> 1000000000 +0000",
message: "init\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
ExGitObjectstore.create_branch(repo, "main", commit_sha)
# Get src/ subtree
{:ok, src_tree} = ExGitObjectstore.tree(repo, "main", "src")
assert %Tree{entries: [entry]} = src_tree
assert entry.name == "main.ex"
end
end
describe "blob" do
test "get blob content by SHA", %{repo: repo} do
blob = Blob.from_content("test content")
{:ok, sha} = Object.write(repo, blob)
assert {:ok, "test content"} = ExGitObjectstore.blob(repo, sha)
end
test "blob_size returns byte count", %{repo: repo} do
blob = Blob.from_content("12345")
{:ok, sha} = Object.write(repo, blob)
assert {:ok, 5} = ExGitObjectstore.blob_size(repo, sha)
end
end
end