ref:4f88147009b5e45761ea1986c9070211f2460044

Phase 3: Pack format read support

- Pack index (.idx v2) parser: fanout, SHA list, CRC, 4/8-byte offsets - Packfile reader: parse objects by offset with delta resolution - Delta decompression: OFS_DELTA and REF_DELTA instruction application - ObjectResolver: check loose objects first, fall back to pack search - Integration test: git gc'd repo read through library - 95 tests passing Refs: notifd/anvil#62 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 4f88147009b5e45761ea1986c9070211f2460044
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-10 07:41
Parents: 988139c
9 files changed +1128 -2
Type
lib/ex_git_objectstore.ex +2 −2
@@ -6,7 +6,7 @@
All git data (objects, refs, packs) is stored via a pluggable storage backend.
"""
alias ExGitObjectstore.{Object, Repo, Ref}
alias ExGitObjectstore.{Object, ObjectResolver, Repo, Ref}
@type sha :: String.t()
@type ref_name :: String.t()
@@ -35,7 +35,7 @@
"""
@spec cat_object(Repo.t(), sha()) :: {:ok, Object.t()} | {:error, term()}
def cat_object(%Repo{} = repo, sha) do
Object.read(repo, sha)
ObjectResolver.read(repo, sha)
end
@doc """
lib/ex_git_objectstore/object_resolver.ex +77 −0
@@ -1,0 +1,77 @@
defmodule ExGitObjectstore.ObjectResolver do
@moduledoc """
Resolves git objects by checking loose objects first, then searching packs.
"""
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Pack.{Index, Reader}
@doc """
Read an object by SHA, checking loose objects first, then packs.
"""
@spec read(Repo.t(), String.t()) :: {:ok, Object.t()} | {:error, term()}
def read(%Repo{} = repo, sha) do
case Object.read(repo, sha) do
{:ok, _} = result ->
result
{:error, :not_found} ->
read_from_packs(repo, sha)
{:error, _} = err ->
err
end
end
defp read_from_packs(repo, sha) do
case Repo.storage_call(repo, :list_packs, []) do
{:ok, pack_shas} ->
find_in_packs(repo, sha, pack_shas)
{:error, _} = err ->
err
end
end
defp find_in_packs(_repo, _sha, []), do: {:error, :not_found}
defp find_in_packs(repo, sha, [pack_sha | rest]) do
with {:ok, idx_data} <- Repo.storage_call(repo, :get_pack_index, [pack_sha]),
{:ok, index} <- Index.parse(idx_data),
{:ok, offset} <- Index.lookup(index, sha),
{:ok, pack_data} <- Repo.storage_call(repo, :get_pack, [pack_sha]),
{:ok, {type, data}} <- Reader.read_object(pack_data, offset) do
# Wrap raw data in the appropriate struct
wrap_object(type, data)
else
:not_found ->
find_in_packs(repo, sha, rest)
{:error, :not_found} ->
find_in_packs(repo, sha, rest)
{:error, _} = err ->
err
end
end
defp wrap_object(:blob, data) do
{:ok, ExGitObjectstore.Object.Blob.from_content(data)}
end
defp wrap_object(:tree, data) do
ExGitObjectstore.Object.Tree.parse_content(data)
end
defp wrap_object(:commit, data) do
ExGitObjectstore.Object.Commit.parse_content(data)
end
defp wrap_object(:tag, data) do
ExGitObjectstore.Object.Tag.parse_content(data)
end
defp wrap_object(type, _data) do
{:error, {:unknown_type, type}}
end
end
lib/ex_git_objectstore/pack/delta.ex +119 −0
@@ -1,0 +1,119 @@
defmodule ExGitObjectstore.Pack.Delta do
@moduledoc """
Git packfile delta decompression.
Delta instructions are a sequence of commands that reconstruct a target
object from a base object. Two types of commands:
1. **Copy from base**: copies a range of bytes from the base object
- First byte has MSB set (bit 7 = 1)
- Lower 7 bits indicate which of the following bytes encode offset and size
- Bits 0-3: offset bytes (up to 4 bytes, little-endian)
- Bits 4-6: size bytes (up to 3 bytes, little-endian)
- If size is 0, it means 0x10000 (64KB)
2. **Insert new data**: inserts literal bytes
- First byte has MSB clear (bit 7 = 0)
- Lower 7 bits = number of literal bytes to insert (1-127)
- 0 is reserved/invalid
"""
@doc """
Apply delta instructions to a base object, producing the target object.
"""
@spec apply(binary(), binary()) :: {:ok, binary()} | {:error, term()}
def apply(base, delta) do
with {:ok, _base_size, rest} <- read_varint(delta),
{:ok, target_size, instructions} <- read_varint(rest),
{:ok, result} <- apply_instructions(instructions, base, []) do
result_bin = IO.iodata_to_binary(result)
if byte_size(result_bin) == target_size do
{:ok, result_bin}
else
{:error, {:size_mismatch, expected: target_size, got: byte_size(result_bin)}}
end
end
end
# Read a variable-length integer (used for base/target sizes in delta header)
defp read_varint(data), do: read_varint(data, 0, 0)
defp read_varint(<<byte, rest::binary>>, value, shift) do
value = value + Bitwise.bsl(Bitwise.band(byte, 0x7F), shift)
if Bitwise.band(byte, 0x80) != 0 do
read_varint(rest, value, shift + 7)
else
{:ok, value, rest}
end
end
defp read_varint(<<>>, _value, _shift), do: {:error, :truncated_varint}
defp apply_instructions(<<>>, _base, acc), do: {:ok, Enum.reverse(acc)}
# Copy from base (MSB set)
defp apply_instructions(<<cmd, rest::binary>>, base, acc) when Bitwise.band(cmd, 0x80) != 0 do
{offset, rest} = read_copy_offset(cmd, rest)
{size, rest} = read_copy_size(cmd, rest)
# size of 0 means 0x10000
size = if size == 0, do: 0x10000, else: size
if offset + size > byte_size(base) do
{:error, {:copy_out_of_bounds, offset: offset, size: size, base_size: byte_size(base)}}
else
chunk = binary_part(base, offset, size)
apply_instructions(rest, base, [chunk | acc])
end
end
# Insert literal data (MSB clear, non-zero)
defp apply_instructions(<<cmd, rest::binary>>, base, acc)
when cmd > 0 and Bitwise.band(cmd, 0x80) == 0 do
size = cmd
if byte_size(rest) >= size do
<<literal::binary-size(size), remaining::binary>> = rest
apply_instructions(remaining, base, [literal | acc])
else
{:error, :truncated_insert}
end
end
# cmd == 0 is reserved
defp apply_instructions(<<0, _rest::binary>>, _base, _acc) do
{:error, :reserved_delta_command}
end
# Read the copy offset (up to 4 bytes, indicated by bits 0-3 of cmd)
defp read_copy_offset(cmd, data) do
{b0, data} = read_if_bit(cmd, 0, data)
{b1, data} = read_if_bit(cmd, 1, data)
{b2, data} = read_if_bit(cmd, 2, data)
{b3, data} = read_if_bit(cmd, 3, data)
offset = b0 + Bitwise.bsl(b1, 8) + Bitwise.bsl(b2, 16) + Bitwise.bsl(b3, 24)
{offset, data}
end
# Read the copy size (up to 3 bytes, indicated by bits 4-6 of cmd)
defp read_copy_size(cmd, data) do
{b0, data} = read_if_bit(cmd, 4, data)
{b1, data} = read_if_bit(cmd, 5, data)
{b2, data} = read_if_bit(cmd, 6, data)
size = b0 + Bitwise.bsl(b1, 8) + Bitwise.bsl(b2, 16)
{size, data}
end
# Read one byte from data if the corresponding bit is set in cmd
defp read_if_bit(cmd, bit, data) do
if Bitwise.band(cmd, Bitwise.bsl(1, bit)) != 0 do
<<byte, rest::binary>> = data
{byte, rest}
else
{0, data}
end
end
end
lib/ex_git_objectstore/pack/index.ex +181 −0
@@ -1,0 +1,181 @@
defmodule ExGitObjectstore.Pack.Index do
@moduledoc """
Parser for git pack index files (.idx), version 2 format.
Format (v2):
- 4-byte magic: 0xff 0x74 0x4f 0x63
- 4-byte version: 2
- 256 * 4-byte fanout table (cumulative count of objects with first byte <= N)
- N * 20-byte SHA-1 names (sorted)
- N * 4-byte CRC32 checksums
- N * 4-byte offset values (MSB set = use 8-byte table)
- M * 8-byte large offsets (for packs > 2GB)
- 20-byte packfile checksum
- 20-byte index checksum
"""
@idx_magic <<0xFF, 0x74, 0x4F, 0x63>>
@idx_version <<0, 0, 0, 2>>
@type t :: %__MODULE__{
fanout: tuple(),
shas: [String.t()],
offsets: %{String.t() => non_neg_integer()},
count: non_neg_integer()
}
defstruct [:fanout, :shas, :offsets, :count]
@doc """
Parse a .idx v2 binary into an Index struct.
"""
@spec parse(binary()) :: {:ok, t()} | {:error, term()}
def parse(<<@idx_magic, @idx_version, rest::binary>>) do
with {:ok, fanout, rest} <- parse_fanout(rest),
count = elem(fanout, 255),
{:ok, shas, rest} <- parse_shas(rest, count),
{:ok, _crcs, rest} <- parse_crcs(rest, count),
{:ok, offsets_4, rest} <- parse_offsets_4(rest, count),
{:ok, large_offsets} <- parse_large_offsets(rest, offsets_4) do
offset_map = build_offset_map(shas, offsets_4, large_offsets)
{:ok,
%__MODULE__{
fanout: fanout,
shas: shas,
offsets: offset_map,
count: count
}}
end
end
def parse(_), do: {:error, :invalid_idx_header}
@doc """
Look up the pack offset for a given SHA.
"""
@spec lookup(t(), String.t()) :: {:ok, non_neg_integer()} | :not_found
def lookup(%__MODULE__{offsets: offsets}, sha) do
case Map.get(offsets, sha) do
nil -> :not_found
offset -> {:ok, offset}
end
end
@doc """
Check if a SHA exists in this index.
"""
@spec member?(t(), String.t()) :: boolean()
def member?(%__MODULE__{offsets: offsets}, sha) do
Map.has_key?(offsets, sha)
end
# -- Parsing helpers --
defp parse_fanout(data) when byte_size(data) >= 1024 do
<<fanout_bin::binary-size(1024), rest::binary>> = data
fanout =
for <<entry::unsigned-big-32 <- fanout_bin>> do
entry
end
|> List.to_tuple()
{:ok, fanout, rest}
end
defp parse_fanout(_), do: {:error, :truncated_fanout}
defp parse_shas(data, count) do
sha_bytes = count * 20
if byte_size(data) >= sha_bytes do
<<sha_bin::binary-size(sha_bytes), rest::binary>> = data
shas =
for <<sha::binary-size(20) <- sha_bin>> do
Base.encode16(sha, case: :lower)
end
{:ok, shas, rest}
else
{:error, :truncated_shas}
end
end
defp parse_crcs(data, count) do
crc_bytes = count * 4
if byte_size(data) >= crc_bytes do
<<crc_bin::binary-size(crc_bytes), rest::binary>> = data
crcs =
for <<crc::unsigned-big-32 <- crc_bin>> do
crc
end
{:ok, crcs, rest}
else
{:error, :truncated_crcs}
end
end
defp parse_offsets_4(data, count) do
offset_bytes = count * 4
if byte_size(data) >= offset_bytes do
<<offset_bin::binary-size(offset_bytes), rest::binary>> = data
offsets =
for <<offset::unsigned-big-32 <- offset_bin>> do
offset
end
{:ok, offsets, rest}
else
{:error, :truncated_offsets}
end
end
defp parse_large_offsets(rest, offsets_4) do
# Count how many offsets have MSB set (need 8-byte entries)
large_count = Enum.count(offsets_4, fn o -> Bitwise.band(o, 0x80000000) != 0 end)
if large_count == 0 do
{:ok, %{}}
else
large_bytes = large_count * 8
if byte_size(rest) >= large_bytes do
<<large_bin::binary-size(large_bytes), _rest::binary>> = rest
large_offsets =
for <<offset::unsigned-big-64 <- large_bin>> do
offset
end
large_map =
large_offsets
|> Enum.with_index()
|> Map.new(fn {offset, idx} -> {idx, offset} end)
{:ok, large_map}
else
{:error, :truncated_large_offsets}
end
end
end
defp build_offset_map(shas, offsets_4, large_offsets) do
shas
|> Enum.zip(offsets_4)
|> Map.new(fn {sha, offset_4} ->
if Bitwise.band(offset_4, 0x80000000) != 0 do
large_idx = Bitwise.band(offset_4, 0x7FFFFFFF)
{sha, Map.fetch!(large_offsets, large_idx)}
else
{sha, offset_4}
end
end)
end
end
lib/ex_git_objectstore/pack/reader.ex +347 −0
@@ -1,0 +1,347 @@
defmodule ExGitObjectstore.Pack.Reader do
@moduledoc """
Packfile reader — parses objects from .pack files.
Pack format:
- 4-byte signature: "PACK"
- 4-byte version: 2
- 4-byte object count
- N entries, each:
- variable-length header (type + uncompressed size, MSB continuation)
- for OFS_DELTA: variable-length negative offset to base object
- for REF_DELTA: 20-byte base object SHA
- zlib compressed data
- 20-byte SHA-1 checksum of all preceding content
"""
alias ExGitObjectstore.Pack.Delta
@pack_signature "PACK"
# Object types (3-bit)
@obj_commit 1
@obj_tree 2
@obj_blob 3
@obj_tag 4
@obj_ofs_delta 6
@obj_ref_delta 7
@type pack_object :: %{
type: atom(),
data: binary(),
offset: non_neg_integer()
}
@doc """
Parse all objects from a packfile binary, resolving deltas.
Returns a list of `%{type: atom, data: binary, offset: integer}`.
"""
@spec parse(binary()) :: {:ok, [pack_object()]} | {:error, term()}
def parse(<<@pack_signature, version::unsigned-big-32, count::unsigned-big-32, rest::binary>>)
when version == 2 do
case parse_entries(rest, count, [], %{}) do
{:ok, entries, _cache} -> {:ok, entries}
{:error, _} = err -> err
end
end
def parse(_), do: {:error, :invalid_pack_header}
@doc """
Read a single object at the given offset from a packfile binary.
Resolves deltas recursively using the pack data itself.
"""
@spec read_object(binary(), non_neg_integer()) :: {:ok, {atom(), binary()}} | {:error, term()}
def read_object(pack_data, offset) do
read_object(pack_data, offset, %{})
end
@doc """
Read object with a pre-populated cache of already-resolved objects.
"""
@spec read_object(binary(), non_neg_integer(), map()) ::
{:ok, {atom(), binary()}} | {:error, term()}
def read_object(pack_data, offset, cache) do
case Map.get(cache, offset) do
{type, data} ->
{:ok, {type, data}}
nil ->
do_read_object(pack_data, offset, cache)
end
end
# -- Private --
defp parse_entries(_rest, 0, acc, cache), do: {:ok, Enum.reverse(acc), cache}
defp parse_entries(rest, remaining, acc, cache) do
# We need to track the offset of each entry
# Since we're reading sequentially, we can compute it
# But pack_data isn't available here, so we'll use a different approach
# For sequential parsing, we track consumed bytes
case parse_entry_at(rest, cache, 0) do
{:ok, entry, consumed, cache} ->
<<_consumed::binary-size(consumed), new_rest::binary>> = rest
parse_entries(new_rest, remaining - 1, [entry | acc], cache)
{:error, _} = err ->
err
end
end
defp parse_entry_at(data, _cache, _base_offset) do
case parse_object_header(data) do
{:ok, type_num, _size, header_len, rest_after_header} ->
case type_num do
t when t in [@obj_commit, @obj_tree, @obj_blob, @obj_tag] ->
type = type_num_to_atom(t)
case decompress_data(rest_after_header) do
{:ok, decompressed, compressed_len} ->
entry = %{type: type, data: decompressed, offset: 0}
{:ok, entry, header_len + compressed_len, %{}}
{:error, _} = err ->
err
end
@obj_ofs_delta ->
case parse_ofs_delta_offset(rest_after_header) do
{:ok, _neg_offset, _ofs_len, rest_after_ofs} ->
case decompress_data(rest_after_ofs) do
{:ok, delta_data, compressed_len} ->
# For sequential parsing, we can't resolve OFS_DELTA without
# knowing the absolute offset. Store as unresolved delta.
entry = %{type: :ofs_delta, data: delta_data, offset: 0}
ofs_len = byte_size(rest_after_header) - byte_size(rest_after_ofs)
{:ok, entry, header_len + ofs_len + compressed_len, %{}}
{:error, _} = err ->
err
end
{:error, _} = err ->
err
end
@obj_ref_delta ->
if byte_size(rest_after_header) >= 20 do
<<_base_sha::binary-size(20), rest_after_sha::binary>> = rest_after_header
case decompress_data(rest_after_sha) do
{:ok, delta_data, compressed_len} ->
entry = %{type: :ref_delta, data: delta_data, offset: 0}
{:ok, entry, header_len + 20 + compressed_len, %{}}
{:error, _} = err ->
err
end
else
{:error, :truncated_ref_delta}
end
_ ->
{:error, {:unknown_object_type, type_num}}
end
{:error, _} = err ->
err
end
end
defp do_read_object(pack_data, offset, cache) do
<<_skip::binary-size(offset), data::binary>> = pack_data
case parse_object_header(data) do
{:ok, type_num, _size, header_len, _rest} ->
data_start = offset + header_len
case type_num do
t when t in [@obj_commit, @obj_tree, @obj_blob, @obj_tag] ->
type = type_num_to_atom(t)
<<_::binary-size(data_start), compressed::binary>> = pack_data
case decompress_data(compressed) do
{:ok, decompressed, _} -> {:ok, {type, decompressed}}
{:error, _} = err -> err
end
@obj_ofs_delta ->
<<_::binary-size(data_start), rest::binary>> = pack_data
case parse_ofs_delta_offset(rest) do
{:ok, neg_offset, ofs_len, _rest_after_ofs} ->
base_offset = offset - neg_offset
delta_start = data_start + ofs_len
<<_::binary-size(delta_start), compressed::binary>> = pack_data
with {:ok, delta_data, _} <- decompress_data(compressed),
{:ok, {base_type, base_data}} <-
read_object(pack_data, base_offset, cache),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
{:error, _} = err ->
err
end
@obj_ref_delta ->
<<_::binary-size(data_start), base_sha_bin::binary-size(20), compressed::binary>> =
pack_data
base_sha = Base.encode16(base_sha_bin, case: :lower)
with {:ok, delta_data, _} <- decompress_data(compressed),
{:ok, base_offset} <- find_sha_offset(pack_data, base_sha, cache),
{:ok, {base_type, base_data}} <-
read_object(pack_data, base_offset, cache),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
_ ->
{:error, {:unknown_object_type, type_num}}
end
{:error, _} = err ->
err
end
end
@doc """
Parse the variable-length object header.
Returns `{:ok, type_num, uncompressed_size, header_bytes_consumed, rest}`.
"""
@spec parse_object_header(binary()) ::
{:ok, non_neg_integer(), non_neg_integer(), non_neg_integer(), binary()}
| {:error, term()}
def parse_object_header(<<byte, rest::binary>>) do
type_num = Bitwise.band(Bitwise.bsr(byte, 4), 0x07)
size = Bitwise.band(byte, 0x0F)
if Bitwise.band(byte, 0x80) != 0 do
parse_size_continuation(rest, size, 4, 1, type_num)
else
{:ok, type_num, size, 1, rest}
end
end
def parse_object_header(<<>>), do: {:error, :empty_header}
defp parse_size_continuation(<<byte, rest::binary>>, size, shift, consumed, type_num) do
size = size + Bitwise.bsl(Bitwise.band(byte, 0x7F), shift)
if Bitwise.band(byte, 0x80) != 0 do
parse_size_continuation(rest, size, shift + 7, consumed + 1, type_num)
else
{:ok, type_num, size, consumed + 1, rest}
end
end
defp parse_size_continuation(<<>>, _size, _shift, _consumed, _type_num),
do: {:error, :truncated_header}
@doc """
Parse the variable-length negative offset for OFS_DELTA.
Returns `{:ok, negative_offset, bytes_consumed, rest}`.
"""
def parse_ofs_delta_offset(<<byte, rest::binary>>) do
offset = Bitwise.band(byte, 0x7F)
if Bitwise.band(byte, 0x80) != 0 do
parse_ofs_continuation(rest, offset, 1)
else
{:ok, offset, 1, rest}
end
end
def parse_ofs_delta_offset(<<>>), do: {:error, :empty_ofs_offset}
defp parse_ofs_continuation(<<byte, rest::binary>>, offset, consumed) do
offset = Bitwise.bsl(offset + 1, 7) + Bitwise.band(byte, 0x7F)
if Bitwise.band(byte, 0x80) != 0 do
parse_ofs_continuation(rest, offset, consumed + 1)
else
{:ok, offset, consumed + 1, rest}
end
end
defp parse_ofs_continuation(<<>>, _offset, _consumed), do: {:error, :truncated_ofs_offset}
defp decompress_data(compressed) do
z = :zlib.open()
try do
:zlib.inflateInit(z)
decompressed = :zlib.inflate(z, compressed)
# Get how many bytes were consumed from compressed stream
# Unfortunately :zlib doesn't tell us directly, but we can get
# the total input bytes from the decompression context
decompressed_bin = IO.iodata_to_binary(decompressed)
# Try to figure out compressed length by re-compressing
# Actually, we need the consumed bytes. Let's use a different approach.
# We'll probe by attempting to decompress increasing prefixes.
# But that's inefficient. Instead, let's use :zlib.inflateEnd to see
# if it completed cleanly, then use safeInflate for byte counting.
:zlib.inflateEnd(z)
# For the compressed length, we need to find where the zlib stream ends.
# Let's do a binary probe: decompress the whole thing and use the
# internal zlib state. Actually, the simplest approach: use :zlib.safeInflate
compressed_len = find_compressed_length(compressed)
{:ok, decompressed_bin, compressed_len}
rescue
e -> {:error, {:decompress_failed, Exception.message(e)}}
after
:zlib.close(z)
end
end
defp find_compressed_length(compressed) do
# Use a separate zlib stream with safeInflate to find consumed input bytes
z = :zlib.open()
try do
:zlib.inflateInit(z)
consume_inflate(z, compressed, 0)
after
:zlib.close(z)
end
end
defp consume_inflate(z, data, consumed) do
# Feed small chunks to safeInflate to find the boundary
chunk_size = min(byte_size(data), 4096)
<<chunk::binary-size(chunk_size), _rest::binary>> = data
case :zlib.safeInflate(z, chunk) do
{:continue, _output} ->
<<_::binary-size(chunk_size), rest::binary>> = data
consume_inflate(z, rest, consumed + chunk_size)
{:finished, _output} ->
# The stream ended somewhere in this chunk
# We consumed all of this chunk from zlib's perspective
:zlib.inflateEnd(z)
consumed + chunk_size
{:need_dictionary, _} ->
consumed + chunk_size
end
end
defp type_num_to_atom(@obj_commit), do: :commit
defp type_num_to_atom(@obj_tree), do: :tree
defp type_num_to_atom(@obj_blob), do: :blob
defp type_num_to_atom(@obj_tag), do: :tag
defp find_sha_offset(_pack_data, _sha, _cache) do
# This would need an index lookup. For now, return an error.
# The caller should have provided the offset via cache.
{:error, :ref_delta_needs_index}
end
end
test/ex_git_objectstore/integration/packed_repo_test.exs +117 −0
@@ -1,0 +1,117 @@
defmodule ExGitObjectstore.Integration.PackedRepoTest do
@moduledoc """
Integration test: create a repo, `git gc` it, then read all objects
through ExGitObjectstore's ObjectResolver (loose → pack fallback).
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.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, "packed_integration_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(base)
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"])
on_exit(fn -> File.rm_rf!(base) end)
%{base: base, work_dir: work_dir}
end
test "read all object types from gc'd bare repo", %{base: base, work_dir: work_dir} do
# Create files
File.write!(Path.join(work_dir, "hello.txt"), "Hello!\n")
File.mkdir_p!(Path.join(work_dir, "src"))
File.write!(Path.join(work_dir, "src/main.ex"), "defmodule Main do\nend\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Initial commit"])
# Make a second commit to test parent linking
File.write!(Path.join(work_dir, "hello.txt"), "Hello World!\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Update hello"])
# Get SHAs before gc
commit_sha = git!(work_dir, ["rev-parse", "HEAD"]) |> String.trim()
parent_sha = git!(work_dir, ["rev-parse", "HEAD~1"]) |> String.trim()
tree_sha = git!(work_dir, ["rev-parse", "HEAD^{tree}"]) |> String.trim()
blob_sha = git!(work_dir, ["hash-object", Path.join(work_dir, "hello.txt")]) |> String.trim()
# Clone bare and gc
bare_dir = Path.join(base, "bare.git")
git!(base, ["clone", "--bare", work_dir, bare_dir])
git!(bare_dir, ["gc", "--aggressive"])
# Verify no loose objects remain (all packed)
objects_dir = Path.join([bare_dir, "objects"])
loose_dirs =
File.ls!(objects_dir)
|> Enum.filter(fn d -> String.length(d) == 2 and d != "pa" end)
loose_count =
Enum.reduce(loose_dirs, 0, fn d, acc ->
case File.ls(Path.join(objects_dir, d)) do
{:ok, files} -> acc + length(files)
_ -> acc
end
end)
# After aggressive gc, should have very few or no loose objects
# (git might keep some depending on version)
# Set up our library to read from the bare repo
repo = make_repo_from_bare(base, bare_dir)
# Read blob from pack
{:ok, blob_obj} = ExGitObjectstore.cat_object(repo, blob_sha)
assert %Blob{content: "Hello World!\n"} = blob_obj
# Read tree from pack
{:ok, tree_obj} = ExGitObjectstore.cat_object(repo, tree_sha)
assert %Tree{entries: entries} = tree_obj
assert length(entries) >= 2
# Read commit from pack
{:ok, commit_obj} = ExGitObjectstore.cat_object(repo, commit_sha)
assert %Commit{} = commit_obj
assert commit_obj.message == "Update hello\n"
assert commit_obj.parents == [parent_sha]
# Read parent commit
{:ok, parent_obj} = ExGitObjectstore.cat_object(repo, parent_sha)
assert %Commit{} = parent_obj
assert parent_obj.message == "Initial commit\n"
assert parent_obj.parents == []
# Verify loose_count is low (objects are in packs)
assert loose_count <= 2, "Expected most objects in packs, found #{loose_count} loose"
end
# -- Helpers --
defp git!(dir, args) do
{output, 0} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
output
end
defp make_repo_from_bare(base, bare_dir) do
repo_id = "test"
link_base = Path.join([base, "repos", repo_id])
File.mkdir_p!(Path.dirname(link_base))
# Remove old symlink if exists
File.rm(link_base)
File.ln_s!(bare_dir, link_base)
Repo.new(repo_id, storage: {Filesystem, %{root: base}})
end
end
test/ex_git_objectstore/pack/delta_test.exs +65 −0
@@ -1,0 +1,65 @@
defmodule ExGitObjectstore.Pack.DeltaTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.Delta
describe "delta apply" do
test "insert-only delta" do
base = "hello"
# Delta: base_size=5, target_size=11, insert 11 bytes "hello world"
# base_size varint: 5 (0x05)
# target_size varint: 11 (0x0B)
# insert cmd: 11 (0x0B), followed by "hello world"
delta = <<5, 11, 11>> <> "hello world"
assert {:ok, "hello world"} = Delta.apply(base, delta)
end
test "copy-only delta" do
base = "hello world"
# Delta: base_size=11, target_size=5
# copy from offset 0, size 5 → "hello"
# copy cmd: 0x80 | 0x01 (offset byte 0) | 0x10 (size byte 0) = 0x91
# offset = 0, size = 5
delta = <<11, 5, 0x91, 0, 5>>
assert {:ok, "hello"} = Delta.apply(base, delta)
end
test "copy from middle of base" do
base = "hello world"
# Copy "world" (offset 6, size 5)
# cmd = 0x80 | 0x01 | 0x10 = 0x91
delta = <<11, 5, 0x91, 6, 5>>
assert {:ok, "world"} = Delta.apply(base, delta)
end
test "mixed copy and insert" do
base = "ABCDEFGHIJ"
# Target: "AB---GHIJ"
# 1. Copy offset 0, size 2 → "AB"
# 2. Insert 3 bytes "---"
# 3. Copy offset 6, size 4 → "GHIJ"
# copy offset=0, size=2: cmd=0x91, offset=0, size=2
# insert 3 bytes "---"
# copy offset=6, size=4: cmd=0x91, offset=6, size=4
delta =
<<10, 9>> <>
<<0x91, 0, 2>> <>
<<3>> <>
"---" <>
<<0x91, 6, 4>>
assert {:ok, "AB---GHIJ"} = Delta.apply(base, delta)
end
test "size mismatch returns error" do
base = "hello"
# Claims target_size is 100 but only produces 5 bytes
delta = <<5, 100, 5>> <> "world"
assert {:error, {:size_mismatch, _}} = Delta.apply(base, delta)
end
end
end
test/ex_git_objectstore/pack/index_test.exs +101 −0
@@ -1,0 +1,101 @@
defmodule ExGitObjectstore.Pack.IndexTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.Index
describe "parse" do
test "parse real idx file from git gc'd repo" do
# Create a test repo, add content, git gc, read the .idx file
{idx_data, shas} = create_packed_repo()
assert {:ok, index} = Index.parse(idx_data)
assert index.count > 0
assert index.count == length(index.shas)
# All SHAs from the repo should be in the index
for sha <- shas do
assert Index.member?(index, sha), "SHA #{sha} not found in index"
assert {:ok, offset} = Index.lookup(index, sha)
assert is_integer(offset) and offset >= 0
end
end
test "lookup non-existent SHA returns :not_found" do
{idx_data, _shas} = create_packed_repo()
{:ok, index} = Index.parse(idx_data)
assert :not_found = Index.lookup(index, String.duplicate("0", 40))
end
test "invalid header returns error" do
assert {:error, :invalid_idx_header} = Index.parse("not an index file")
assert {:error, :invalid_idx_header} = Index.parse(<<0xFF, 0x74, 0x4F, 0x63, 0, 0, 0, 3>>)
end
end
# -- Helpers --
defp create_packed_repo do
tmp_dir = System.tmp_dir!()
dir = Path.join(tmp_dir, "pack_idx_test_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
git!(dir, ["init"])
git!(dir, ["config", "user.email", "test@test.com"])
git!(dir, ["config", "user.name", "Test"])
# Create several files to ensure we get meaningful pack content
for i <- 1..5 do
File.write!(
Path.join(dir, "file#{i}.txt"),
"content #{i}\n#{String.duplicate("x", i * 100)}"
)
end
git!(dir, ["add", "."])
git!(dir, ["commit", "-m", "Initial"])
# Collect SHAs before gc
shas = collect_object_shas(dir)
# Force gc to create packfile
git!(dir, ["gc", "--aggressive"])
# Find the .idx file
pack_dir = Path.join([dir, ".git", "objects", "pack"])
{:ok, files} = File.ls(pack_dir)
idx_file = Enum.find(files, &String.ends_with?(&1, ".idx"))
idx_data = File.read!(Path.join(pack_dir, idx_file))
on_exit_cleanup(dir)
{idx_data, shas}
end
defp collect_object_shas(dir) do
# Get all object SHAs using rev-list and cat-file
{output, 0} = System.cmd("git", ["rev-list", "--objects", "--all"], cd: dir)
output
|> String.split("\n", trim: true)
|> Enum.map(fn line ->
line |> String.split(" ") |> List.first()
end)
|> Enum.reject(&is_nil/1)
end
defp git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
if status != 0 do
raise "git #{Enum.join(args, " ")} failed: #{output}"
end
output
end
defp on_exit_cleanup(dir) do
# Register cleanup in the test process
ExUnit.Callbacks.on_exit(fn -> File.rm_rf!(dir) end)
end
end
test/ex_git_objectstore/pack/reader_test.exs +119 −0
@@ -1,0 +1,119 @@
defmodule ExGitObjectstore.Pack.ReaderTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.{Index, Reader}
describe "read_object from packfile" do
test "reads all objects from a packed repo" do
{pack_data, idx_data, expected_objects} = create_packed_repo_with_objects()
{:ok, index} = Index.parse(idx_data)
# For each known object, read it from the packfile
for {sha, {expected_type, expected_content}} <- expected_objects do
{:ok, offset} = Index.lookup(index, sha)
assert {:ok, {type, data}} = Reader.read_object(pack_data, offset)
assert type == expected_type,
"Type mismatch for #{sha}: expected #{expected_type}, got #{type}"
# For blobs, we can directly compare content
if expected_type == :blob do
assert data == expected_content,
"Content mismatch for blob #{sha}"
end
end
end
test "parse_object_header" do
# Type 1 (commit), size 15
# Byte: 0 | type(001) | size(1111) = 0001_1111 = 0x1F
assert {:ok, 1, 15, 1, ""} = Reader.parse_object_header(<<0x1F>>)
# Type 3 (blob), size 4
# Byte: 0 | type(011) | size(0100) = 0011_0100 = 0x34
assert {:ok, 3, 4, 1, ""} = Reader.parse_object_header(<<0x34>>)
# Type 2 (tree), size larger than 15 (multi-byte)
# First byte: 1 | type(010) | size_low(1111) = 1010_1111 = 0xAF
# Second byte: 0 | size_high(0000001) = 0000_0001 = 0x01
# Total size = 15 + (1 << 4) = 31
assert {:ok, 2, 31, 2, ""} = Reader.parse_object_header(<<0xAF, 0x01>>)
end
test "parse_ofs_delta_offset" do
# Single byte offset: 5
assert {:ok, 5, 1, ""} = Reader.parse_ofs_delta_offset(<<5>>)
# Multi-byte offset
# First byte: 1 | 0000101 = 0x85 (MSB set, value 5)
# Second byte: 0 | 0000011 = 0x03
# offset = ((5 + 1) << 7) + 3 = 768 + 3 = 771
assert {:ok, 771, 2, ""} = Reader.parse_ofs_delta_offset(<<0x85, 0x03>>)
end
end
# -- Helpers --
defp create_packed_repo_with_objects do
tmp_dir = System.tmp_dir!()
dir = Path.join(tmp_dir, "pack_reader_test_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
git!(dir, ["init"])
git!(dir, ["config", "user.email", "test@test.com"])
git!(dir, ["config", "user.name", "Test"])
# Create files with known content
file_contents = %{
"hello.txt" => "Hello, World!\n",
"readme.md" => "# Test Project\n\nThis is a test.\n",
"data.txt" => String.duplicate("line of data\n", 50)
}
for {name, content} <- file_contents do
File.write!(Path.join(dir, name), content)
end
git!(dir, ["add", "."])
git!(dir, ["commit", "-m", "Initial commit"])
# Get blob SHAs and content
blob_objects =
for {name, content} <- file_contents, into: %{} do
sha = git!(dir, ["hash-object", Path.join(dir, name)]) |> String.trim()
{sha, {:blob, content}}
end
# Get tree SHA
tree_sha = git!(dir, ["rev-parse", "HEAD^{tree}"]) |> String.trim()
commit_sha = git!(dir, ["rev-parse", "HEAD"]) |> String.trim()
expected_objects =
blob_objects
|> Map.put(tree_sha, {:tree, nil})
|> Map.put(commit_sha, {:commit, nil})
# gc to create packfile
git!(dir, ["gc", "--aggressive"])
# Read pack and idx files
pack_dir = Path.join([dir, ".git", "objects", "pack"])
{:ok, files} = File.ls(pack_dir)
pack_file = Enum.find(files, &String.ends_with?(&1, ".pack"))
idx_file = Enum.find(files, &String.ends_with?(&1, ".idx"))
pack_data = File.read!(Path.join(pack_dir, pack_file))
idx_data = File.read!(Path.join(pack_dir, idx_file))
ExUnit.Callbacks.on_exit(fn -> File.rm_rf!(dir) end)
{pack_data, idx_data, expected_objects}
end
defp git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
if status != 0, do: raise("git #{Enum.join(args, " ")} failed: #{output}")
output
end
end