ref:988139cdd359b83d29ac5bfaaf08095237bdfae3

Phase 2: Storage backends + ref management

- Filesystem storage backend with S3-compatible key layout - S3 storage backend via ex_aws_s3 - packed-refs support for reading real git bare repos - Integration tests: create repos with git CLI, read through library - Filesystem tests: full CRUD for objects, refs, HEAD, packs - 83 tests passing Refs: notifd/anvil#62 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 988139cdd359b83d29ac5bfaaf08095237bdfae3
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-10 07:38
Parents: 2984a0d
4 files changed +915 -0
Type
lib/ex_git_objectstore/storage/filesystem.ex +303 −0
@@ -1,0 +1,303 @@
defmodule ExGitObjectstore.Storage.Filesystem do
@moduledoc """
Local filesystem storage backend.
Mirrors the S3 key layout on disk:
<root>/repos/<repo-id>/
HEAD
refs/heads/<branch>
refs/tags/<tag>
objects/<sha[0:2]>/<sha[2:]>
objects/pack/pack-<sha>.pack
objects/pack/pack-<sha>.idx
Config: `%{root: "/path/to/storage"}`
"""
@behaviour ExGitObjectstore.Storage
# -- Object operations --
@impl true
def get_object(config, prefix, sha) do
path = object_path(config, prefix, sha)
case File.read(path) do
{:ok, data} -> {:ok, data}
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
@impl true
def put_object(config, prefix, sha, data) do
path = object_path(config, prefix, sha)
File.mkdir_p!(Path.dirname(path))
File.write!(path, data)
:ok
end
@impl true
def object_exists?(config, prefix, sha) do
File.exists?(object_path(config, prefix, sha))
end
# -- Pack operations --
@impl true
def list_packs(config, prefix) do
pack_dir = Path.join([config.root, prefix, "objects", "pack"])
case File.ls(pack_dir) do
{:ok, files} ->
packs =
files
|> Enum.filter(&String.ends_with?(&1, ".pack"))
|> Enum.map(fn file ->
file
|> String.replace_prefix("pack-", "")
|> String.replace_suffix(".pack", "")
end)
{:ok, packs}
{:error, :enoent} ->
{:ok, []}
{:error, reason} ->
{:error, reason}
end
end
@impl true
def get_pack(config, prefix, pack_sha) do
path = pack_path(config, prefix, pack_sha, "pack")
case File.read(path) do
{:ok, data} -> {:ok, data}
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
@impl true
def get_pack_index(config, prefix, pack_sha) do
path = pack_path(config, prefix, pack_sha, "idx")
case File.read(path) do
{:ok, data} -> {:ok, data}
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
@impl true
def put_pack(config, prefix, pack_sha, pack_data, idx_data) do
pack_p = pack_path(config, prefix, pack_sha, "pack")
idx_p = pack_path(config, prefix, pack_sha, "idx")
File.mkdir_p!(Path.dirname(pack_p))
File.write!(pack_p, pack_data)
File.write!(idx_p, idx_data)
:ok
end
@impl true
def stream_pack(config, prefix, pack_sha) do
path = pack_path(config, prefix, pack_sha, "pack")
if File.exists?(path) do
stream = File.stream!(path, 65_536)
{:ok, stream}
else
{:error, :not_found}
end
end
# -- Ref operations --
@impl true
def get_ref(config, prefix, ref_path) do
path = Path.join([config.root, prefix, ref_path])
case File.read(path) do
{:ok, data} -> {:ok, String.trim(data)}
{:error, :enoent} -> get_packed_ref(config, prefix, ref_path)
{:error, reason} -> {:error, reason}
end
end
@impl true
def put_ref(config, prefix, ref_path, new_sha, old_sha) do
path = Path.join([config.root, prefix, ref_path])
if old_sha do
# CAS: check current value
case File.read(path) do
{:ok, data} ->
if String.trim(data) == old_sha do
File.mkdir_p!(Path.dirname(path))
File.write!(path, new_sha <> "\n")
:ok
else
{:error, :cas_failed}
end
{:error, :enoent} ->
{:error, :cas_failed}
{:error, reason} ->
{:error, reason}
end
else
File.mkdir_p!(Path.dirname(path))
File.write!(path, new_sha <> "\n")
:ok
end
end
@impl true
def delete_ref(config, prefix, ref_path) do
path = Path.join([config.root, prefix, ref_path])
case File.rm(path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
@impl true
def list_refs(config, prefix, ref_prefix) do
base_dir = Path.join([config.root, prefix, ref_prefix])
# Start with loose refs
loose_refs =
case list_files_recursive(base_dir) do
{:ok, files} ->
Enum.map(files, fn file ->
ref_name = ref_prefix <> Path.relative_to(file, base_dir)
{:ok, sha} = File.read(file)
{ref_name, String.trim(sha)}
end)
{:error, _} ->
[]
end
# Merge with packed-refs (loose refs take precedence)
packed_refs = list_packed_refs(config, prefix, ref_prefix)
loose_names = MapSet.new(loose_refs, &elem(&1, 0))
merged =
packed_refs
|> Enum.reject(fn {name, _sha} -> MapSet.member?(loose_names, name) end)
|> Enum.concat(loose_refs)
|> Enum.sort()
{:ok, merged}
end
# -- HEAD --
@impl true
def get_head(config, prefix) do
path = Path.join([config.root, prefix, "HEAD"])
case File.read(path) do
{:ok, data} -> {:ok, String.trim(data)}
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
@impl true
def put_head(config, prefix, target) do
path = Path.join([config.root, prefix, "HEAD"])
File.mkdir_p!(Path.dirname(path))
File.write!(path, target <> "\n")
:ok
end
# -- Private --
defp object_path(config, prefix, sha) do
<<dir::binary-size(2), rest::binary>> = sha
Path.join([config.root, prefix, "objects", dir, rest])
end
defp pack_path(config, prefix, pack_sha, ext) do
Path.join([config.root, prefix, "objects", "pack", "pack-#{pack_sha}.#{ext}"])
end
defp get_packed_ref(config, prefix, ref_path) do
packed_refs_path = Path.join([config.root, prefix, "packed-refs"])
case File.read(packed_refs_path) do
{:ok, content} ->
case parse_packed_refs(content) |> Map.get(ref_path) do
nil -> {:error, :not_found}
sha -> {:ok, sha}
end
{:error, :enoent} ->
{:error, :not_found}
{:error, reason} ->
{:error, reason}
end
end
defp list_packed_refs(config, prefix, ref_prefix) do
packed_refs_path = Path.join([config.root, prefix, "packed-refs"])
case File.read(packed_refs_path) do
{:ok, content} ->
parse_packed_refs(content)
|> Enum.filter(fn {name, _sha} -> String.starts_with?(name, ref_prefix) end)
{:error, _} ->
[]
end
end
defp parse_packed_refs(content) do
content
|> String.split("\n")
|> Enum.reject(fn line ->
line == "" or String.starts_with?(line, "#") or String.starts_with?(line, "^")
end)
|> Enum.map(fn line ->
case String.split(line, " ", parts: 2) do
[sha, ref_name] -> {ref_name, sha}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
end
defp list_files_recursive(dir) do
case File.ls(dir) do
{:ok, entries} ->
files =
Enum.flat_map(entries, fn entry ->
path = Path.join(dir, entry)
if File.dir?(path) do
case list_files_recursive(path) do
{:ok, sub_files} -> sub_files
_ -> []
end
else
[path]
end
end)
{:ok, files}
{:error, reason} ->
{:error, reason}
end
end
end
lib/ex_git_objectstore/storage/s3.ex +260 −0
@@ -1,0 +1,260 @@
defmodule ExGitObjectstore.Storage.S3 do
@moduledoc """
S3-compatible storage backend (AWS S3, MinIO, etc.).
Config:
%{
bucket: "my-bucket",
ex_aws_config: [ # optional, passed to ExAws operations
access_key_id: "...",
secret_access_key: "...",
region: "us-east-1",
host: "localhost", # for MinIO
port: 9000,
scheme: "http://"
]
}
S3 key layout:
<prefix>/HEAD
<prefix>/refs/heads/<branch>
<prefix>/objects/<sha[0:2]>/<sha[2:]>
<prefix>/objects/pack/pack-<sha>.pack
<prefix>/objects/pack/pack-<sha>.idx
"""
@behaviour ExGitObjectstore.Storage
# -- Object operations --
@impl true
def get_object(config, prefix, sha) do
key = object_key(prefix, sha)
s3_get(config, key)
end
@impl true
def put_object(config, prefix, sha, data) do
key = object_key(prefix, sha)
s3_put(config, key, data)
end
@impl true
def object_exists?(config, prefix, sha) do
key = object_key(prefix, sha)
case s3_head(config, key) do
:ok -> true
{:error, _} -> false
end
end
# -- Pack operations --
@impl true
def list_packs(config, prefix) do
pack_prefix = "#{prefix}/objects/pack/pack-"
case s3_list(config, pack_prefix) do
{:ok, keys} ->
packs =
keys
|> Enum.filter(&String.ends_with?(&1, ".pack"))
|> Enum.map(fn key ->
key
|> String.replace_prefix(pack_prefix, "")
|> String.replace_suffix(".pack", "")
end)
{:ok, packs}
{:error, _} = err ->
err
end
end
@impl true
def get_pack(config, prefix, pack_sha) do
key = pack_key(prefix, pack_sha, "pack")
s3_get(config, key)
end
@impl true
def get_pack_index(config, prefix, pack_sha) do
key = pack_key(prefix, pack_sha, "idx")
s3_get(config, key)
end
@impl true
def put_pack(config, prefix, pack_sha, pack_data, idx_data) do
with :ok <- s3_put(config, pack_key(prefix, pack_sha, "pack"), pack_data),
:ok <- s3_put(config, pack_key(prefix, pack_sha, "idx"), idx_data) do
:ok
end
end
@impl true
def stream_pack(config, prefix, pack_sha) do
# For S3, we download the full pack and wrap in a list.
# For large packs, a chunked Range-request approach would be better.
case get_pack(config, prefix, pack_sha) do
{:ok, data} -> {:ok, [data]}
{:error, _} = err -> err
end
end
# -- Ref operations --
@impl true
def get_ref(config, prefix, ref_path) do
key = "#{prefix}/#{ref_path}"
case s3_get(config, key) do
{:ok, data} -> {:ok, String.trim(data)}
{:error, _} = err -> err
end
end
@impl true
def put_ref(config, prefix, ref_path, new_sha, old_sha) do
key = "#{prefix}/#{ref_path}"
if old_sha do
# CAS: read current value first
case s3_get(config, key) do
{:ok, data} ->
if String.trim(data) == old_sha do
s3_put(config, key, new_sha <> "\n")
else
{:error, :cas_failed}
end
{:error, :not_found} ->
{:error, :cas_failed}
{:error, _} = err ->
err
end
else
s3_put(config, key, new_sha <> "\n")
end
end
@impl true
def delete_ref(config, prefix, ref_path) do
key = "#{prefix}/#{ref_path}"
s3_delete(config, key)
end
@impl true
def list_refs(config, prefix, ref_prefix) do
full_prefix = "#{prefix}/#{ref_prefix}"
case s3_list(config, full_prefix) do
{:ok, keys} ->
refs =
keys
|> Enum.map(fn key ->
ref_name = String.replace_prefix(key, "#{prefix}/", "")
case s3_get(config, key) do
{:ok, data} -> {ref_name, String.trim(data)}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort()
{:ok, refs}
{:error, _} = err ->
err
end
end
# -- HEAD --
@impl true
def get_head(config, prefix) do
key = "#{prefix}/HEAD"
case s3_get(config, key) do
{:ok, data} -> {:ok, String.trim(data)}
{:error, _} = err -> err
end
end
@impl true
def put_head(config, prefix, target) do
key = "#{prefix}/HEAD"
s3_put(config, key, target <> "\n")
end
# -- Private S3 helpers --
defp object_key(prefix, sha) do
<<dir::binary-size(2), rest::binary>> = sha
"#{prefix}/objects/#{dir}/#{rest}"
end
defp pack_key(prefix, pack_sha, ext) do
"#{prefix}/objects/pack/pack-#{pack_sha}.#{ext}"
end
defp s3_get(config, key) do
op = ExAws.S3.get_object(config.bucket, key)
case ExAws.request(op, ex_aws_config(config)) do
{:ok, %{body: body}} -> {:ok, body}
{:error, {:http_error, 404, _}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
defp s3_put(config, key, data) do
op = ExAws.S3.put_object(config.bucket, key, data)
case ExAws.request(op, ex_aws_config(config)) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp s3_delete(config, key) do
op = ExAws.S3.delete_object(config.bucket, key)
case ExAws.request(op, ex_aws_config(config)) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp s3_head(config, key) do
op = ExAws.S3.head_object(config.bucket, key)
case ExAws.request(op, ex_aws_config(config)) do
{:ok, _} -> :ok
{:error, {:http_error, 404, _}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
defp s3_list(config, prefix) do
op = ExAws.S3.list_objects_v2(config.bucket, prefix: prefix)
case ExAws.request(op, ex_aws_config(config)) do
{:ok, %{body: %{contents: contents}}} ->
keys = Enum.map(contents, & &1.key)
{:ok, keys}
{:ok, %{body: _}} ->
{:ok, []}
{:error, reason} ->
{:error, reason}
end
end
defp ex_aws_config(%{ex_aws_config: config}), do: config
defp ex_aws_config(_), do: []
end
test/ex_git_objectstore/integration/git_repo_test.exs +209 −0
@@ -1,0 +1,209 @@
defmodule ExGitObjectstore.Integration.GitRepoTest do
@moduledoc """
Integration tests that create real git repos with the git CLI,
then verify our library can read the objects correctly.
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Tree, Commit}
alias ExGitObjectstore.Storage.Filesystem
@moduletag :integration
setup do
tmp_dir = System.tmp_dir!()
base = Path.join(tmp_dir, "git_integration_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(base)
# Create a working git repo
work_dir = Path.join(base, "work")
File.mkdir_p!(work_dir)
git!(work_dir, ["init"])
git!(work_dir, ["config", "user.email", "test@example.com"])
git!(work_dir, ["config", "user.name", "Test User"])
# Create a bare clone for our library to read from
bare_dir = Path.join(base, "bare.git")
on_exit(fn -> File.rm_rf!(base) end)
%{base: base, work_dir: work_dir, bare_dir: bare_dir}
end
describe "reading loose objects from a git repo" do
test "read blob created by git", %{work_dir: work_dir, base: base} do
# Create a file and commit it
File.write!(Path.join(work_dir, "hello.txt"), "Hello, World!\n")
git!(work_dir, ["add", "hello.txt"])
git!(work_dir, ["commit", "-m", "Add hello.txt"])
# Get the blob SHA from git
blob_sha = git!(work_dir, ["hash-object", "hello.txt"]) |> String.trim()
# Create a bare clone
bare_dir = Path.join(base, "bare.git")
git!(base, ["clone", "--bare", work_dir, bare_dir])
# Now read through our library using filesystem backend
# The bare repo's object store is at bare_dir/objects/
# We need to map our storage to point at the bare repo
# Our layout is: <root>/<prefix>/objects/... so we set root and prefix accordingly
repo = make_repo_from_bare(bare_dir)
{:ok, decoded} = Object.read(repo, blob_sha)
assert %Blob{content: "Hello, World!\n"} = decoded
end
test "read tree created by git", %{work_dir: work_dir, base: base} do
File.write!(Path.join(work_dir, "a.txt"), "aaa\n")
File.write!(Path.join(work_dir, "b.txt"), "bbb\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Add files"])
# Get tree SHA
tree_sha = git!(work_dir, ["rev-parse", "HEAD^{tree}"]) |> String.trim()
bare_dir = Path.join(base, "bare.git")
git!(base, ["clone", "--bare", work_dir, bare_dir])
repo = make_repo_from_bare(bare_dir)
{:ok, decoded} = Object.read(repo, tree_sha)
assert %Tree{entries: entries} = decoded
names = Enum.map(entries, & &1.name)
assert "a.txt" in names
assert "b.txt" in names
end
test "read commit created by git", %{work_dir: work_dir, base: base} do
File.write!(Path.join(work_dir, "file.txt"), "content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Initial commit"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"]) |> String.trim()
bare_dir = Path.join(base, "bare.git")
git!(base, ["clone", "--bare", work_dir, bare_dir])
repo = make_repo_from_bare(bare_dir)
{:ok, decoded} = Object.read(repo, commit_sha)
assert %Commit{} = decoded
assert decoded.message == "Initial commit\n"
assert decoded.author =~ "Test User"
assert decoded.parents == []
end
test "read commit with parent", %{work_dir: work_dir, base: base} do
File.write!(Path.join(work_dir, "file.txt"), "v1\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "First"])
parent_sha = git!(work_dir, ["rev-parse", "HEAD"]) |> String.trim()
File.write!(Path.join(work_dir, "file.txt"), "v2\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Second"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"]) |> String.trim()
bare_dir = Path.join(base, "bare.git")
git!(base, ["clone", "--bare", work_dir, bare_dir])
repo = make_repo_from_bare(bare_dir)
{:ok, decoded} = Object.read(repo, commit_sha)
assert decoded.parents == [parent_sha]
end
test "read refs from bare repo", %{work_dir: work_dir, base: base} do
File.write!(Path.join(work_dir, "file.txt"), "content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Init"])
# Create a branch
git!(work_dir, ["branch", "feature"])
bare_dir = Path.join(base, "bare.git")
git!(base, ["clone", "--bare", work_dir, bare_dir])
repo = make_repo_from_bare(bare_dir)
# Check HEAD
assert {:ok, head} = ExGitObjectstore.Ref.get_head(repo)
assert head =~ "ref: refs/heads/"
# Check branches
{:ok, branches} = ExGitObjectstore.branches(repo)
branch_names = Enum.map(branches, &elem(&1, 0))
assert "refs/heads/main" in branch_names or "refs/heads/master" in branch_names
assert "refs/heads/feature" in branch_names
end
test "our SHA matches git's SHA for objects we create", %{base: _base} do
# Verify that blobs we create in-memory have the same SHA as git would produce
blob = Blob.from_content("verification test\n")
our_sha = Object.hash(blob)
# Write to a temp file and hash with git
tmp = Path.join(System.tmp_dir!(), "verify_#{:erlang.unique_integer([:positive])}")
File.write!(tmp, "verification test\n")
git_sha = System.cmd("git", ["hash-object", tmp]) |> elem(0) |> String.trim()
File.rm!(tmp)
assert our_sha == git_sha
end
end
describe "reading nested trees" do
test "tree with subdirectory", %{work_dir: work_dir, base: base} do
File.mkdir_p!(Path.join(work_dir, "src"))
File.write!(Path.join(work_dir, "src/main.ex"), "defmodule Main do\nend\n")
File.write!(Path.join(work_dir, "README.md"), "# Project\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Add src and readme"])
tree_sha = git!(work_dir, ["rev-parse", "HEAD^{tree}"]) |> String.trim()
bare_dir = Path.join(base, "bare.git")
git!(base, ["clone", "--bare", work_dir, bare_dir])
repo = make_repo_from_bare(bare_dir)
{:ok, decoded} = Object.read(repo, tree_sha)
assert %Tree{entries: entries} = decoded
names = Enum.map(entries, & &1.name)
assert "README.md" in names
assert "src" in names
# Find the src directory entry and read its tree
src_entry = Enum.find(entries, &(&1.name == "src"))
assert src_entry.mode == "40000"
{:ok, src_tree} = Object.read(repo, src_entry.sha)
assert %Tree{entries: [src_file]} = src_tree
assert src_file.name == "main.ex"
end
end
# -- Helpers --
defp git!(dir, args) do
{output, 0} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
output
end
# Map a bare git repo to our storage layout.
# Bare repo has objects at <bare_dir>/objects/<sha[0:2]>/<sha[2:]>
# Our layout is: <root>/<prefix>/objects/<sha[0:2]>/<sha[2:]>
# So root=<parent_of_bare_dir>, prefix=<bare_dir_name minus .git>
# Actually easier: root=<bare_dir>, prefix="" but our Repo prefixes with "repos/<id>"
# Simplest: make root point so that root/repos/<id> = bare_dir
defp make_repo_from_bare(bare_dir) do
repo_id = "test"
# We need root/repos/test = bare_dir, so root = bare_dir/../ and symlink
parent = Path.dirname(bare_dir)
link_base = Path.join([parent, "repos", repo_id])
File.mkdir_p!(Path.dirname(link_base))
File.ln_s!(bare_dir, link_base)
Repo.new(repo_id, storage: {Filesystem, %{root: parent}})
end
end
test/ex_git_objectstore/storage/filesystem_test.exs +143 −0
@@ -1,0 +1,143 @@
defmodule ExGitObjectstore.Storage.FilesystemTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Storage.Filesystem
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
setup do
tmp_dir = System.tmp_dir!()
root = Path.join(tmp_dir, "fs_store_test_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(root)
repo =
Repo.new("test-repo", storage: {Filesystem, %{root: root}})
on_exit(fn -> File.rm_rf!(root) end)
%{repo: repo, root: root}
end
describe "object storage" do
test "write and read a blob", %{repo: repo} do
blob = Blob.from_content("hello world")
{:ok, sha} = Object.write(repo, blob)
{:ok, decoded} = Object.read(repo, sha)
assert %Blob{content: "hello world"} = decoded
end
test "object is stored in <sha[0:2]>/<sha[2:]> layout", %{repo: repo, root: root} do
blob = Blob.from_content("test")
{:ok, sha} = Object.write(repo, blob)
<<dir::binary-size(2), rest::binary>> = sha
path = Path.join([root, "repos/test-repo/objects", dir, rest])
assert File.exists?(path)
end
test "read non-existent object", %{repo: repo} do
assert {:error, :not_found} = Object.read(repo, String.duplicate("0", 40))
end
test "object_exists?", %{repo: repo} do
blob = Blob.from_content("check")
sha = Object.hash(blob)
refute Repo.storage_call(repo, :object_exists?, [sha])
{:ok, _} = Object.write(repo, blob)
assert Repo.storage_call(repo, :object_exists?, [sha])
end
end
describe "ref storage" do
test "put and get ref", %{repo: repo} do
sha = String.duplicate("a", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha, nil])
assert {:ok, ^sha} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "CAS put_ref", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
wrong = String.duplicate("c", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
assert :ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, sha1])
assert {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, wrong])
end
test "delete ref", %{repo: repo} do
sha = String.duplicate("a", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha, nil])
:ok = Repo.storage_call(repo, :delete_ref, ["refs/heads/main"])
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "list refs", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, nil])
:ok = Repo.storage_call(repo, :put_ref, ["refs/heads/dev", sha2, nil])
:ok = Repo.storage_call(repo, :put_ref, ["refs/tags/v1", sha1, nil])
{:ok, heads} = Repo.storage_call(repo, :list_refs, ["refs/heads/"])
assert length(heads) == 2
ref_names = Enum.map(heads, &elem(&1, 0))
assert "refs/heads/dev" in ref_names
assert "refs/heads/main" in ref_names
end
end
describe "HEAD" do
test "put and get HEAD", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_head, ["ref: refs/heads/main"])
assert {:ok, "ref: refs/heads/main"} = Repo.storage_call(repo, :get_head, [])
end
end
describe "pack storage" do
test "put, get, list packs", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["abc123", "pack-data", "idx-data"])
assert {:ok, "pack-data"} = Repo.storage_call(repo, :get_pack, ["abc123"])
assert {:ok, "idx-data"} = Repo.storage_call(repo, :get_pack_index, ["abc123"])
assert {:ok, ["abc123"]} = Repo.storage_call(repo, :list_packs, [])
end
test "stream_pack", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["def456", "stream-data", "idx"])
{:ok, stream} = Repo.storage_call(repo, :stream_pack, ["def456"])
result = stream |> Enum.to_list() |> IO.iodata_to_binary()
assert result == "stream-data"
end
end
describe "full round-trip through ExGitObjectstore API" do
test "init, write objects, create branch, resolve", %{repo: repo} do
:ok = ExGitObjectstore.init(repo)
assert {:ok, "main"} = ExGitObjectstore.default_branch(repo)
blob = Blob.from_content("# Hello\n")
{:ok, blob_sha} = ExGitObjectstore.write_object(repo, blob)
tree = Tree.new([%{mode: "100644", name: "README.md", sha: blob_sha}])
{:ok, tree_sha} = ExGitObjectstore.write_object(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1234567890 +0000",
committer: "Test <test@test.com> 1234567890 +0000",
message: "init\n"
}
{:ok, commit_sha} = ExGitObjectstore.write_object(repo, commit)
:ok = ExGitObjectstore.create_branch(repo, "main", commit_sha)
assert {:ok, ^commit_sha} = ExGitObjectstore.resolve(repo, "main")
assert {:ok, ^commit_sha} = ExGitObjectstore.resolve(repo, "HEAD")
end
end
end