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