ref:372b793731fde596f5901bcaff88187831ea5398

Merge pull request #2 from notifd/fix/code-review-all-issues

Prepare for initial Hex.pm release
SHA: 372b793731fde596f5901bcaff88187831ea5398
Author: Cole Christensen <cole.christensen+github@gmail.com>
Date: 2026-02-10 23:49
63 files changed +2843 -183
Type
.github/workflows/ci.yml +121 −0
@@ -1,0 +1,121 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
MIX_ENV: test
ELIXIR_VERSION: "1.17"
OTP_VERSION: "27"
jobs:
compile:
name: Compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- run: mix deps.get
- run: mix compile --warnings-as-errors
test:
name: Test
needs: compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- run: mix deps.get
- run: mix test
format:
name: Format
needs: compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- run: mix deps.get
- run: mix format --check-formatted
license:
name: License Check
needs: compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- run: mix deps.get
- run: mix license_check
dialyzer:
name: Dialyzer
needs: compile
runs-on: ubuntu-latest
env:
MIX_ENV: dev
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
id: beam
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-dev-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-dev-
- uses: actions/cache@v4
with:
path: priv/plts
key: plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('mix.lock') }}
- run: mix deps.get
- run: mkdir -p priv/plts
- run: mix dialyzer --plt
- run: mix dialyzer --format github
.github/workflows/release.yml +58 −0
@@ -1,0 +1,58 @@
name: Release
on:
push:
branches: [main]
permissions:
contents: write
jobs:
release:
name: Release to Hex.pm
runs-on: ubuntu-latest
# Only run when a version-tagged commit lands on main
if: startsWith(github.event.head_commit.message, 'Release v')
env:
MIX_ENV: prod
ELIXIR_VERSION: "1.17"
OTP_VERSION: "27"
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- run: mix deps.get
- run: mix compile
- name: Publish to Hex.pm
run: mix hex.publish --yes
env:
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}
- name: Extract version
id: version
run: |
VERSION=$(mix eval "IO.puts(Mix.Project.config()[:version])" | head -1)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
run: |
gh release create "v${{ steps.version.outputs.version }}" \
--title "v${{ steps.version.outputs.version }}" \
--generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build docs
env:
MIX_ENV: dev
run: |
mix deps.get
mix docs
- name: Publish docs to Hex.pm
run: mix hex.publish docs --yes
env:
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}
.gitignore +3 −0
@@ -25,3 +25,6 @@
# Temporary files, for example, from tests.
/tmp/
# Dialyzer PLT files
/priv/plts/
CHANGELOG.md +26 −0
@@ -1,0 +1,26 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-02-10
### Added
- Git object encoding/decoding (blob, tree, commit, tag) with SHA-1 hashing
- Pluggable storage backends: Filesystem, S3, and Memory
- Ref management: branches, tags, HEAD, compare-and-swap updates
- Packfile support: read/write `.pack` and `.idx` v2 files
- Delta resolution (OFS_DELTA) in pack reader
- Three-way recursive tree merge with conflict detection
- Myers diff algorithm with unified diff output and context hunks
- Commit log traversal and merge base (LCA) finding
- Git wire protocol: pkt-line framing, upload-pack, receive-pack
- ETS-based LRU object cache with configurable size limits
- Atomic file writes and lock-file CAS for filesystem storage
- Path traversal prevention in filesystem storage
- Input validation for refs, SHAs, tree entry names, pack counts
- Apache 2.0 license with headers on all source files
- `mix license_check` task for CI enforcement
lib/ex_git_objectstore.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore do
@moduledoc """
Pure Elixir Git library with pluggable object storage (S3, filesystem, memory).
lib/ex_git_objectstore/cache/ets.ex +70 −26
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Cache.ETS do
@moduledoc """
ETS-based LRU cache for git objects and pack indexes.
@@ -5,11 +19,11 @@
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}
Uses a separate ordered_set ETS table as an LRU index to avoid
O(n log n) full-table scans during eviction.
The caller is responsible for creating the ETS table before use:
:ets.new(:my_cache, [:set, :public, :named_table])
Config:
%{table: :my_cache_table, lru_table: :my_lru_table, max_entries: 10_000}
"""
@doc """
@@ -27,18 +41,40 @@
write_concurrency: true
])
lru_table =
%{table: table, max_entries: max_entries}
:ets.new(:"#{table_name}_lru", [
:ordered_set,
:public,
write_concurrency: true
])
%{table: table, lru_table: lru_table, max_entries: max_entries}
end
@doc """
Destroy the cache tables, freeing memory.
"""
@spec destroy(map()) :: :ok
def destroy(%{table: table, lru_table: lru_table}) do
:ets.delete(table)
:ets.delete(lru_table)
:ok
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()})
def get(%{table: table, lru_table: lru_table}, repo_id, key) do
cache_key = {repo_id, key}
case :ets.lookup(table, cache_key) do
[{_, value, old_ts}] ->
new_ts = System.monotonic_time()
# Update access time in both tables (best-effort, races are acceptable for LRU)
:ets.update_element(table, cache_key, {3, new_ts})
:ets.delete(lru_table, {old_ts, cache_key})
:ets.insert(lru_table, {{new_ts, cache_key}})
{:ok, value}
[] ->
@@ -50,14 +86,16 @@
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
def put(%{table: table, lru_table: lru_table, max_entries: max_entries}, repo_id, key, value) do
size = :ets.info(table, :size)
if size >= max_entries do
evict_oldest(table, div(max_entries, 10))
evict_oldest(table, lru_table, div(max_entries, 10))
end
ts = System.monotonic_time()
:ets.insert(table, {{repo_id, key}, value, System.monotonic_time()})
cache_key = {repo_id, key}
:ets.insert(table, {cache_key, value, ts})
:ets.insert(lru_table, {{ts, cache_key}})
:ok
end
@@ -75,7 +113,10 @@
Clear all entries for a specific repo.
"""
@spec clear_repo(map(), String.t()) :: :ok
def clear_repo(%{table: table}, repo_id) do
def clear_repo(%{table: table, lru_table: lru_table}, repo_id) do
:ets.match_delete(table, {{repo_id, :_}, :_, :_})
# Clearing LRU entries for a specific repo requires a scan,
# but it's better to just rebuild on next eviction
:ets.delete_all_objects(lru_table)
:ok
end
@@ -84,21 +125,24 @@
Clear the entire cache.
"""
@spec clear(map()) :: :ok
def clear(%{table: table}) do
def clear(%{table: table, lru_table: lru_table}) do
:ets.delete_all_objects(table)
:ets.delete_all_objects(lru_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)
# Evict the N oldest entries using the ordered LRU index — O(N) not O(total)
defp evict_oldest(table, lru_table, count) do
do_evict(table, lru_table, count, :ets.first(lru_table))
end
for {key, _value, _ts} <- entries do
:ets.delete(table, key)
end
defp do_evict(_table, _lru_table, 0, _key), do: :ok
defp do_evict(_table, _lru_table, _remaining, :"$end_of_table"), do: :ok
defp do_evict(table, lru_table, remaining, {_ts, cache_key} = lru_key) do
next = :ets.next(lru_table, lru_key)
:ets.delete(table, cache_key)
:ets.delete(lru_table, lru_key)
do_evict(table, lru_table, remaining - 1, next)
end
end
lib/ex_git_objectstore/diff.ex +48 −24
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Diff do
@moduledoc """
Tree-level diff and line-level diff with hunk formatting.
@@ -236,7 +250,16 @@
end
defp group_changes(indexed, context) do
# Find change regions (del/add) and expand with context
indexed_vec = :array.from_list(indexed)
total = :array.size(indexed_vec)
# Build index map for O(1) position lookups instead of O(n) find_index
index_map =
indexed
|> Enum.with_index()
|> Map.new(fn {item, idx} -> {item, idx} end)
# Find change regions using prepend + reverse to avoid O(n²) append
{groups, current_group} =
Enum.reduce(indexed, {[], []}, fn item, {groups, current} ->
case item do
@@ -244,42 +267,43 @@
if current == [] do
{groups, []}
else
{groups, current ++ [item]}
{groups, [item | current]}
end
_ ->
{groups, current ++ [item]}
{groups, [item | current]}
end
end)
all_groups =
if current_group != [] do
[Enum.reverse(current_group) | groups]
all_groups = if current_group != [], do: groups ++ [current_group], else: groups
else
groups
end
|> Enum.reverse()
# Add context lines around each group using pre-computed indices
all_groups
# Add context lines around each group
|> Enum.map(fn group -> add_context(group, indexed_vec, total, index_map, context) end)
Enum.map(all_groups, fn group ->
add_context(group, indexed, context)
end)
|> Enum.reject(&Enum.empty?/1)
end
defp add_context(group, all_indexed, context) do
return_if_empty = fn -> [] end
defp add_context([], _indexed_vec, _total, _index_map, _context), do: []
defp add_context(group, indexed_vec, total, index_map, context) do
first = List.first(group)
last = List.last(group)
case {List.first(group), List.last(group)} do
{nil, _} ->
return_if_empty.()
{first, last} ->
# Find indices in all_indexed
first_idx = Enum.find_index(all_indexed, &(&1 == first))
last_idx = Enum.find_index(all_indexed, &(&1 == last))
first_idx = Map.get(index_map, first)
last_idx = Map.get(index_map, last)
if first_idx == nil or last_idx == nil do
group
else
start_idx = max(0, first_idx - context)
if first_idx == nil or last_idx == nil do
group
else
start_idx = max(0, first_idx - context)
end_idx = min(total - 1, last_idx + context)
end_idx = min(length(all_indexed) - 1, last_idx + context)
Enum.slice(all_indexed, start_idx..end_idx)
end
for i <- start_idx..end_idx, do: :array.get(i, indexed_vec)
end
end
lib/ex_git_objectstore/diff/myers.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Diff.Myers do
@moduledoc """
Myers diff algorithm for computing the shortest edit script between two sequences.
lib/ex_git_objectstore/merge.ex +37 −23
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Merge do
@moduledoc """
Three-way tree merge.
@@ -9,6 +23,8 @@
alias ExGitObjectstore.{Object, ObjectResolver, Repo, Walk}
alias ExGitObjectstore.Object.{Commit, Tree}
@max_tree_depth 64
@type conflict :: %{
path: String.t(),
base: String.t() | nil,
@@ -58,6 +74,6 @@
with {:ok, base_tree} <- read_tree(repo, base_tree_sha),
{:ok, ours_tree} <- read_tree(repo, ours_tree_sha),
{:ok, theirs_tree} <- read_tree(repo, theirs_tree_sha) do
merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, "")
merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, "", 0)
end
end
@@ -73,7 +89,12 @@
end
# Merge tree entries from all three sides
defp merge_tree_entries(_repo, _base_tree, _ours_tree, _theirs_tree, _path_prefix, depth)
when depth > @max_tree_depth do
{:error, :max_tree_depth_exceeded}
end
defp merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, path_prefix) do
defp merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, path_prefix, depth) do
base_map = entries_to_map(base_tree.entries)
ours_map = entries_to_map(ours_tree.entries)
theirs_map = entries_to_map(theirs_tree.entries)
@@ -93,6 +114,6 @@
theirs_entry = Map.get(theirs_map, name)
full_path = join_path(path_prefix, name)
case merge_entry(repo, base_entry, ours_entry, theirs_entry, full_path) do
case merge_entry(repo, base_entry, ours_entry, theirs_entry, full_path, depth) do
{:keep, entry} ->
{[entry | entries_acc], conflicts_acc}
@@ -120,7 +141,7 @@
end
# Three-way merge logic for a single entry
defp merge_entry(repo, base, ours, theirs, path) do
defp merge_entry(repo, base, ours, theirs, path, depth) do
cond do
# All three identical — no change
same_entry?(base, ours) and same_entry?(base, theirs) ->
@@ -140,7 +161,7 @@
# Both changed differently — check if subtrees can be recursively merged
both_are_trees?(ours, theirs) and (base == nil or is_tree_entry?(base)) ->
merge_subtrees(repo, base, ours, theirs, path)
merge_subtrees(repo, base, ours, theirs, path, depth)
# Conflict: both modified differently (blobs), or delete vs modify
true ->
@@ -154,16 +175,12 @@
end
end
defp merge_subtrees(repo, base, ours, theirs, path) do
defp merge_subtrees(repo, base, ours, theirs, path, depth) do
# For recursive subtree merge, we need actual tree objects
base_sha = if base, do: base.sha, else: empty_tree_sha(repo)
ours_sha = ours.sha
with {:ok, base_sha} <- empty_tree_or_sha(repo, base),
{:ok, base_tree} <- read_tree(repo, base_sha),
{:ok, ours_tree} <- read_tree(repo, ours.sha),
theirs_sha = theirs.sha
# Use merge_tree_entries directly to keep path prefix context
with {:ok, base_tree} <- read_tree(repo, base_sha),
{:ok, ours_tree} <- read_tree(repo, ours_sha),
{:ok, theirs_tree} <- read_tree(repo, theirs_sha) do
case merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, path) do
{:ok, theirs_tree} <- read_tree(repo, theirs.sha) do
case merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, path, depth + 1) do
{:ok, merged_sha} ->
{:keep, %{mode: ours.mode, name: ours.name, sha: merged_sha}}
@@ -177,15 +194,12 @@
end
end
# Create an empty tree and return its SHA for base-less subtree merges
defp empty_tree_sha(repo) do
# Return the entry's SHA or write an empty tree for base-less subtree merges
defp empty_tree_or_sha(repo, nil) do
Object.write(repo, Tree.new([]))
end
tree = Tree.new([])
case Object.write(repo, tree) do
{:ok, sha} -> sha
defp empty_tree_or_sha(_repo, entry), do: {:ok, entry.sha}
{:error, _} -> raise "Failed to write empty tree"
end
end
defp entries_to_map(entries) do
Map.new(entries, fn entry -> {entry.name, entry} end)
lib/ex_git_objectstore/object.ex +47 −10
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object do
@moduledoc """
Git object encoding/decoding.
@@ -33,13 +47,25 @@
@doc """
Encode a git object to its stored format (zlib compressed).
Raises on compression failure (extremely rare).
"""
@spec encode(t()) :: binary()
def encode(object) do
case zlib_compress(encode_raw(object)) do
{:ok, compressed} -> compressed
{:error, reason} -> raise "zlib compression failed: #{inspect(reason)}"
end
object |> encode_raw() |> zlib_compress()
end
@doc """
Safe version of encode/1 — returns `{:ok, compressed}` or `{:error, reason}`.
"""
@spec safe_encode(t()) :: {:ok, binary()} | {:error, term()}
def safe_encode(object) do
zlib_compress(encode_raw(object))
end
@doc """
Decode a stored git object (zlib compressed) into a struct.
"""
@spec decode(binary()) :: {:ok, t()} | {:error, term()}
@@ -81,10 +107,15 @@
@spec write(Repo.t(), t()) :: {:ok, String.t()} | {:error, term()}
def write(%Repo{} = repo, object) do
sha = hash(object)
compressed = encode(object)
case safe_encode(object) do
{:ok, compressed} ->
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:ok, sha}
{:error, _} = err -> err
end
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:ok, sha}
{:error, _} = err ->
err
{:error, _} = err -> err
end
end
@@ -141,11 +172,17 @@
defp zlib_compress(data) do
z = :zlib.open()
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
:zlib.close(z)
IO.iodata_to_binary(compressed)
try do
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
{:ok, IO.iodata_to_binary(compressed)}
rescue
e -> {:error, {:compress_failed, Exception.message(e)}}
after
:zlib.close(z)
end
end
defp safe_decompress(data) do
lib/ex_git_objectstore/object/blob.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.Blob do
@moduledoc """
Git blob object — stores file content.
lib/ex_git_objectstore/object/commit.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.Commit do
@moduledoc """
Git commit object.
lib/ex_git_objectstore/object/tag.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.Tag do
@moduledoc """
Git annotated tag object.
lib/ex_git_objectstore/object/tree.ex +31 −1
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.Tree do
@moduledoc """
Git tree object — represents a directory listing.
@@ -21,6 +35,22 @@
end
@doc """
Validate a tree entry name according to git rules.
Returns `:ok` or `{:error, reason}`.
"""
@spec validate_entry_name(String.t()) :: :ok | {:error, term()}
def validate_entry_name(name) do
cond do
name == "" -> {:error, {:invalid_entry_name, name, :empty}}
name == "." -> {:error, {:invalid_entry_name, name, :dot}}
name == ".." -> {:error, {:invalid_entry_name, name, :dotdot}}
String.contains?(name, "/") -> {:error, {:invalid_entry_name, name, :slash}}
String.contains?(name, <<0>>) -> {:error, {:invalid_entry_name, name, :null_byte}}
true -> :ok
end
end
@doc """
Encode tree entries to the binary content format.
Format per entry: `<mode> <name>\0<20-byte-sha>`
"""
@@ -28,7 +58,7 @@
def encode_content(%__MODULE__{entries: entries}) do
entries
|> Enum.map(fn entry ->
sha_bin = Base.decode16!(String.upcase(entry.sha))
{:ok, sha_bin} = Base.decode16(String.upcase(entry.sha))
"#{entry.mode} #{entry.name}\0" <> sha_bin
end)
|> IO.iodata_to_binary()
lib/ex_git_objectstore/object_resolver.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.ObjectResolver do
@moduledoc """
Resolves git objects by checking loose objects first, then searching packs.
lib/ex_git_objectstore/pack/delta.ex +24 −1
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.Delta do
@moduledoc """
Git packfile delta decompression.
@@ -23,7 +37,8 @@
"""
@spec apply(binary(), binary()) :: {:ok, binary()} | {:error, term()}
def apply(base, delta) do
with {:ok, base_size, rest} <- read_varint(delta),
with {:ok, _base_size, rest} <- read_varint(delta),
:ok <- validate_base_size(base, base_size),
{:ok, target_size, instructions} <- read_varint(rest),
{:ok, result} <- apply_instructions(instructions, base, []) do
result_bin = IO.iodata_to_binary(result)
@@ -50,5 +65,13 @@
end
defp read_varint(<<>>, _value, _shift), do: {:error, :truncated_varint}
defp validate_base_size(base, expected_size) do
if byte_size(base) == expected_size do
:ok
else
{:error, {:base_size_mismatch, expected: expected_size, got: byte_size(base)}}
end
end
defp apply_instructions(<<>>, _base, acc), do: {:ok, Enum.reverse(acc)}
lib/ex_git_objectstore/pack/index.ex +58 −13
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.Index do
@moduledoc """
Parser for git pack index files (.idx), version 2 format.
@@ -29,16 +43,19 @@
@doc """
Parse a .idx v2 binary into an Index struct.
"""
@max_pack_objects 10_000_000
@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 <- validate_fanout(fanout),
:ok <- validate_count(count),
{: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, large_offsets} <- parse_large_offsets(rest, offsets_4),
{:ok, offset_map} <- build_offset_map(shas, offsets_4, large_offsets) do
{:ok,
%__MODULE__{
fanout: fanout,
@@ -165,17 +182,45 @@
end
end
end
defp validate_fanout(fanout) do
# Fanout table must be monotonically non-decreasing
values = Tuple.to_list(fanout)
if values == Enum.sort(values) do
:ok
else
{:error, :fanout_not_monotonic}
end
end
defp validate_count(count) when count > @max_pack_objects do
{:error, {:pack_too_large, count}}
end
defp validate_count(_count), do: :ok
defp build_offset_map(shas, offsets_4, large_offsets) do
result =
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)
case Map.get(large_offsets, large_idx) do
nil -> {sha, {:error, {:missing_large_offset, large_idx}}}
offset -> {sha, offset}
end
else
{sha, offset_4}
end
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)
# Check if any entries had errors
case Enum.find(result, fn {_sha, val} -> match?({:error, _}, val) end) do
nil -> {:ok, result}
{_sha, err} -> err
end
end
end
lib/ex_git_objectstore/pack/reader.ex +31 −11
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.Reader do
@moduledoc """
Packfile reader — parses objects from .pack files.
@@ -17,6 +31,7 @@
alias ExGitObjectstore.Pack.Delta
@pack_signature "PACK"
@max_pack_objects 10_000_000
# Object types (3-bit)
@obj_commit 1
@@ -39,8 +54,12 @@
@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
if count > @max_pack_objects do
{:error, {:pack_too_large, count}}
else
case parse_entries(rest, count, [], %{}) do
{:ok, entries, _cache} -> {:ok, entries}
{:error, _} = err -> err
end
end
end
@@ -271,5 +290,8 @@
defp parse_ofs_continuation(<<>>, _offset, _consumed), do: {:error, :truncated_ofs_offset}
# Decompress data and find the exact compressed length.
# Uses inflate for decompression, then binary search to find the minimal
# prefix that produces the same output. This is O(log n) decompressions.
defp decompress_data(compressed) do
z = :zlib.open()
@@ -280,7 +302,6 @@
decompressed_bin = IO.iodata_to_binary(decompressed)
:zlib.inflateEnd(z)
# Find the exact compressed length by re-compressing and probing
compressed_len = find_compressed_length(compressed, byte_size(decompressed_bin))
{:ok, decompressed_bin, compressed_len}
rescue
@@ -290,8 +311,6 @@
end
end
# Find compressed length by binary probing: try increasingly smaller
# prefixes until decompression still produces the same output
defp find_compressed_length(compressed, expected_size) do
total = byte_size(compressed)
probe_compressed_length(compressed, expected_size, 1, total)
@@ -302,11 +321,9 @@
case try_decompress_prefix(compressed, mid) do
{:ok, size} when size == expected_size ->
# This prefix is enough — try shorter
probe_compressed_length(compressed, expected_size, low, mid)
_ ->
# This prefix is not enough — try longer
probe_compressed_length(compressed, expected_size, mid + 1, high)
end
end
@@ -335,9 +352,12 @@
defp type_num_to_atom(@obj_blob), do: :blob
defp type_num_to_atom(@obj_tag), do: :tag
# TODO: Implement REF_DELTA resolution by building an in-memory index from
# the pack data. Currently REF_DELTA objects can only be resolved when the
# caller provides offsets via the cache (e.g., from a .idx file lookup).
# Without this, thin packs from git-fetch that use REF_DELTA will fail.
# See: https://git-scm.com/docs/pack-format#_deltified_representation
defp find_sha_offset(_pack_data, _sha, _cache) do
{:error, :ref_delta_not_supported}
# 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
lib/ex_git_objectstore/pack/writer.ex +27 −7
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.Writer do
@moduledoc """
Generate git packfiles and their corresponding .idx files from a set of objects.
@@ -130,7 +144,7 @@
# SHA table
sha_data =
sorted
|> Enum.map(fn {sha, _offset, _crc} -> Base.decode16!(sha, case: :lower) end)
|> Enum.map(fn {sha, _offset, _crc} -> elem(Base.decode16(sha, case: :mixed), 1) end)
|> IO.iodata_to_binary()
# CRC table
@@ -169,6 +183,6 @@
# 256 entries, each is cumulative count of objects with first SHA byte <= N
sha_first_bytes =
Enum.map(sorted_entries, fn {sha, _offset, _crc} ->
<<first_byte, _rest::binary>> = Base.decode16!(sha, case: :lower)
<<first_byte, _rest::binary>> = elem(Base.decode16(sha, case: :mixed), 1)
first_byte
end)
@@ -209,10 +223,16 @@
defp zlib_compress(data) do
z = :zlib.open()
try do
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
IO.iodata_to_binary(compressed)
rescue
e -> {:error, {:compress_failed, Exception.message(e)}}
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
:zlib.close(z)
IO.iodata_to_binary(compressed)
after
:zlib.close(z)
end
end
end
lib/ex_git_objectstore/protocol/pkt_line.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.PktLine do
@moduledoc """
Git pkt-line format encoding and decoding.
lib/ex_git_objectstore/protocol/receive_pack.ex +30 −6
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.ReceivePack do
@moduledoc """
State machine for the git receive-pack protocol (server side of `git push`).
@@ -18,8 +32,12 @@
alias ExGitObjectstore.Protocol.PktLine
@zero_sha String.duplicate("0", 40)
@capabilities "report-status delete-refs ofs-delta side-band-64k"
# Capabilities we actually implement. Others are scaffolded but not yet functional:
# TODO: ofs-delta — Writer doesn't generate OFS_DELTA yet
# TODO: side-band-64k — not yet used in report responses
@capabilities "report-status delete-refs"
@type command :: %{
ref: String.t(),
old_sha: String.t(),
@@ -314,10 +332,16 @@
defp zlib_compress(data) do
z = :zlib.open()
:zlib.deflateInit(z)
try do
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
:zlib.close(z)
IO.iodata_to_binary(compressed)
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
IO.iodata_to_binary(compressed)
rescue
e -> {:error, {:compress_failed, Exception.message(e)}}
after
:zlib.close(z)
end
end
end
lib/ex_git_objectstore/protocol/upload_pack.ex +33 −3
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.UploadPack do
@moduledoc """
State machine for the git upload-pack protocol (server side of `git fetch/clone`).
@@ -16,8 +30,12 @@
alias ExGitObjectstore.Protocol.PktLine
@zero_sha String.duplicate("0", 40)
@capabilities "multi_ack_detailed side-band-64k ofs-delta"
# Capabilities we actually implement. Others are scaffolded but not yet functional:
# TODO: multi_ack_detailed — requires returning ACK during negotiation, currently NAK-only
# TODO: ofs-delta — reader supports OFS_DELTA, but Writer doesn't generate them yet
@capabilities "side-band-64k"
@type state :: %__MODULE__{
repo: Repo.t(),
phase: :advertise | :negotiation | :pack | :done,
@@ -83,14 +101,16 @@
defp build_advertisement(repo) do
refs = list_all_refs(repo)
symref = head_symref(repo)
caps = build_capabilities(symref)
case refs do
[] ->
line = "#{@zero_sha} capabilities^{}\0 #{caps}"
line = "#{@zero_sha} capabilities^{}\0 #{@capabilities}"
PktLine.encode(line) <> PktLine.flush()
[{first_ref, first_sha} | rest] ->
first_line = "#{first_sha} #{first_ref}\0 #{@capabilities}"
first_line = "#{first_sha} #{first_ref}\0 #{caps}"
lines =
[PktLine.encode(first_line)] ++
@@ -102,6 +122,16 @@
IO.iodata_to_binary(lines)
end
end
defp head_symref(repo) do
case Ref.get_head(repo) do
{:ok, "ref: " <> ref_path} -> ref_path
_ -> nil
end
end
defp build_capabilities(nil), do: @capabilities
defp build_capabilities(ref), do: "#{@capabilities} symref=HEAD:#{ref}"
defp list_all_refs(repo) do
heads =
lib/ex_git_objectstore/ref.ex +63 −1
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Ref do
@moduledoc """
Git reference management — branches, tags, and HEAD.
@@ -18,7 +32,11 @@
"""
@spec put(Repo.t(), String.t(), String.t(), String.t() | nil) :: :ok | {:error, term()}
def put(%Repo{} = repo, ref_path, new_sha, old_sha) do
Repo.storage_call(repo, :put_ref, [ref_path, new_sha, old_sha])
with :ok <- validate_sha(new_sha),
:ok <- validate_ref_name(ref_path),
:ok <- if(old_sha, do: validate_sha(old_sha), else: :ok) do
Repo.storage_call(repo, :put_ref, [ref_path, new_sha, old_sha])
end
end
@doc """
@@ -100,4 +118,48 @@
end
defp sha?(_), do: false
@doc """
Validate that a string is a valid 40-character lowercase hex SHA.
"""
@spec validate_sha(String.t()) :: :ok | {:error, term()}
def validate_sha(sha) do
if sha?(sha) do
:ok
else
{:error, {:invalid_sha, sha}}
end
end
@doc """
Validate a ref name according to git rules.
"""
@spec validate_ref_name(String.t()) :: :ok | {:error, term()}
def validate_ref_name(ref_path) do
cond do
ref_path == "" ->
{:error, {:invalid_ref_name, ref_path, :empty}}
String.contains?(ref_path, "..") ->
{:error, {:invalid_ref_name, ref_path, :dotdot}}
String.ends_with?(ref_path, ".lock") ->
{:error, {:invalid_ref_name, ref_path, :lock_suffix}}
String.contains?(ref_path, "//") ->
{:error, {:invalid_ref_name, ref_path, :double_slash}}
String.contains?(ref_path, <<0>>) ->
{:error, {:invalid_ref_name, ref_path, :null_byte}}
String.ends_with?(ref_path, "/") ->
{:error, {:invalid_ref_name, ref_path, :trailing_slash}}
String.starts_with?(ref_path, "/") ->
{:error, {:invalid_ref_name, ref_path, :leading_slash}}
true ->
:ok
end
end
end
lib/ex_git_objectstore/repo.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Repo do
@moduledoc """
Repository handle. Encapsulates the repo identity and storage backend config.
lib/ex_git_objectstore/storage.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Storage do
@moduledoc """
Behaviour for pluggable git object storage backends.
lib/ex_git_objectstore/storage/filesystem.ex +107 −32
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Storage.Filesystem do
@moduledoc """
Local filesystem storage backend.
@@ -17,6 +31,8 @@
@behaviour ExGitObjectstore.Storage
@max_recursive_depth 20
# -- Object operations --
@impl true
@@ -33,9 +49,7 @@
@impl true
def put_object(config, prefix, sha, data) do
path = object_path(config, prefix, sha)
File.mkdir_p!(Path.dirname(path))
atomic_write(path, data)
File.write!(path, data)
:ok
end
@impl true
@@ -96,10 +110,11 @@
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")
with :ok <- atomic_write(pack_p, pack_data),
:ok <- atomic_write(idx_p, idx_data) do
File.mkdir_p!(Path.dirname(pack_p))
File.write!(pack_p, pack_data)
:ok
end
File.write!(idx_p, idx_data)
:ok
end
@impl true
@@ -130,28 +145,50 @@
@impl true
def put_ref(config, prefix, ref_path, new_sha, old_sha) do
path = Path.join([config.root, prefix, ref_path])
lock_path = path <> ".lock"
File.mkdir_p!(Path.dirname(path))
if old_sha do
# CAS: check current value
case File.read(path) do
{:ok, data} ->
# Use lock file for atomicity
case File.open(lock_path, [:write, :exclusive]) do
{:ok, lock_fd} ->
try do
IO.binwrite(lock_fd, new_sha <> "\n")
File.close(lock_fd)
if old_sha do
# CAS: verify current value under lock
case File.read(path) do
{:ok, data} ->
if String.trim(data) == old_sha do
File.rename!(lock_path, path)
:ok
else
File.rm(lock_path)
{:error, :cas_failed}
end
{:error, :enoent} ->
File.rm(lock_path)
{:error, :cas_failed}
{:error, reason} ->
File.rm(lock_path)
{:error, reason}
end
if String.trim(data) == old_sha do
File.mkdir_p!(Path.dirname(path))
File.write!(path, new_sha <> "\n")
:ok
else
{:error, :cas_failed}
File.rename!(lock_path, path)
:ok
end
rescue
e ->
File.rm(lock_path)
{:error, {:lock_failed, Exception.message(e)}}
end
{:error, :eexist} ->
{:error, :enoent} ->
{:error, :cas_failed}
{:error, :ref_locked}
{:error, reason} ->
{:error, reason}
end
else
{:error, reason} ->
{:error, reason}
File.mkdir_p!(Path.dirname(path))
File.write!(path, new_sha <> "\n")
:ok
end
end
@@ -214,21 +251,53 @@
@impl true
def put_head(config, prefix, target) do
path = Path.join([config.root, prefix, "HEAD"])
atomic_write(path, target <> "\n")
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])
safe_path(config.root, Path.join([prefix, "objects", dir, rest]))
end
defp pack_path(config, prefix, pack_sha, ext) do
safe_path(config.root, Path.join([prefix, "objects", "pack", "pack-#{pack_sha}.#{ext}"]))
end
# Ensure constructed paths cannot escape the storage root via ".." traversal
defp safe_path(root, relative) do
full = Path.join(root, relative) |> Path.expand()
root_expanded = Path.expand(root)
if String.starts_with?(full, root_expanded <> "/") or full == root_expanded do
full
else
raise ArgumentError, "path traversal detected: #{relative}"
Path.join([config.root, prefix, "objects", "pack", "pack-#{pack_sha}.#{ext}"])
end
end
# Atomic write: write to temp file then rename, preventing partial writes
defp atomic_write(path, data) do
dir = Path.dirname(path)
File.mkdir_p!(dir)
tmp_path = path <> ".tmp.#{:erlang.unique_integer([:positive])}"
with :ok <- File.write(tmp_path, data) do
case File.rename(tmp_path, path) do
:ok ->
:ok
{:error, reason} ->
File.rm(tmp_path)
{:error, reason}
end
else
{:error, reason} ->
File.rm(tmp_path)
{:error, reason}
end
end
defp get_packed_ref(config, prefix, ref_path) do
packed_refs_path = Path.join([config.root, prefix, "packed-refs"])
@@ -276,8 +345,14 @@
|> Enum.reject(&is_nil/1)
|> Map.new()
end
defp list_files_recursive(dir), do: list_files_recursive(dir, 0)
defp list_files_recursive(dir) do
defp list_files_recursive(_dir, depth) when depth > @max_recursive_depth do
{:ok, []}
end
defp list_files_recursive(dir, depth) do
case File.ls(dir) do
{:ok, entries} ->
files =
@@ -285,7 +360,7 @@
path = Path.join(dir, entry)
if File.dir?(path) do
case list_files_recursive(path) do
case list_files_recursive(path, depth + 1) do
{:ok, sub_files} -> sub_files
_ -> []
end
lib/ex_git_objectstore/storage/memory.ex +17 −4
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Storage.Memory do
@moduledoc """
In-memory storage backend for testing.
@@ -119,16 +133,15 @@
current = get_in(state, [:refs, key(prefix, ref_path)])
cond do
old_sha == nil and current == nil ->
{:ok, put_in(state, [:refs, key(prefix, ref_path)], new_sha)}
# No CAS requested: unconditional write
old_sha == nil ->
# Creating new ref but it already exists — allow overwrite when no CAS
{:ok, put_in(state, [:refs, key(prefix, ref_path)], new_sha)}
# CAS: current matches expected
current == old_sha ->
{:ok, put_in(state, [:refs, key(prefix, ref_path)], new_sha)}
# CAS: mismatch
true ->
{{:error, :cas_failed}, state}
end
lib/ex_git_objectstore/storage/s3.ex +29 −3
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Storage.S3 do
@moduledoc """
S3-compatible storage backend (AWS S3, MinIO, etc.).
@@ -240,15 +254,27 @@
end
defp s3_list(config, prefix) do
op = ExAws.S3.list_objects_v2(config.bucket, prefix: prefix)
s3_list_all(config, prefix, nil, [])
end
defp s3_list_all(config, prefix, continuation_token, acc) do
opts =
[prefix: prefix] ++
if(continuation_token, do: [continuation_token: continuation_token], else: [])
op = ExAws.S3.list_objects_v2(config.bucket, opts)
case ExAws.request(op, ex_aws_config(config)) do
{:ok, %{body: %{contents: contents, is_truncated: "true", next_continuation_token: token}}} ->
keys = Enum.map(contents, & &1.key)
s3_list_all(config, prefix, token, acc ++ keys)
{:ok, %{body: %{contents: contents}}} ->
keys = Enum.map(contents, & &1.key)
{:ok, keys}
{:ok, acc ++ keys}
{:ok, %{body: _}} ->
{:ok, []}
{:ok, acc}
{:error, reason} ->
{:error, reason}
lib/ex_git_objectstore/walk.ex +30 −6
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Walk do
@moduledoc """
Commit graph traversal — implements `git log` style iteration.
@@ -8,6 +22,8 @@
alias ExGitObjectstore.{ObjectResolver, Repo}
alias ExGitObjectstore.Object.Commit
@default_ancestor_limit 100_000
@type log_opts :: [
max_count: non_neg_integer(),
skip: non_neg_integer(),
@@ -57,11 +73,17 @@
@doc """
Find the merge base (lowest common ancestor) of two commits.
## Options
* `:ancestor_limit` — max commits to walk when building ancestor sets
(default: #{@default_ancestor_limit})
"""
@spec merge_base(Repo.t(), String.t(), String.t(), keyword()) ::
{:ok, String.t()} | {:error, term()}
def merge_base(%Repo{} = repo, sha_a, sha_b, opts \\ []) do
@spec merge_base(Repo.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, term()}
def merge_base(%Repo{} = repo, sha_a, sha_b) do
limit = Keyword.get(opts, :ancestor_limit, @default_ancestor_limit)
# Build ancestor sets for both commits, find the first common ancestor
case {ancestor_set(repo, sha_a), ancestor_set(repo, sha_b)} do
case {ancestor_set(repo, sha_a, limit), ancestor_set(repo, sha_b, limit)} 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)
@@ -116,16 +138,18 @@
)
end
defp parse_timestamp(author_or_committer) when is_binary(author_or_committer) do
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 parse_timestamp(_), do: 0
defp ancestor_set(repo, sha) do
case walk(repo, [sha], MapSet.new(), [], 10_000) do
defp ancestor_set(repo, sha, limit) do
case walk(repo, [sha], MapSet.new(), [], limit) do
{:ok, commits} ->
set = commits |> Enum.map(&elem(&1, 0)) |> MapSet.new()
{:ok, set}
lib/mix/tasks/license_check.ex +65 −0
@@ -1,0 +1,65 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule Mix.Tasks.LicenseCheck do
@moduledoc """
Checks that all .ex and .exs source files contain the Apache 2.0 license header.
## Usage
mix license_check
Returns exit code 1 if any files are missing the header.
"""
@shortdoc "Verify Apache 2.0 license headers on all source files"
use Mix.Task
@expected_first_line "# Copyright 2026 Cole Christensen"
@impl Mix.Task
def run(_args) do
files =
(Path.wildcard("lib/**/*.ex") ++
Path.wildcard("test/**/*.ex") ++
Path.wildcard("test/**/*.exs") ++
["mix.exs"])
|> Enum.filter(&File.exists?/1)
missing =
Enum.filter(files, fn file ->
case File.open(file, [:read, :utf8]) do
{:ok, device} ->
first_line = IO.read(device, :line) |> String.trim_trailing()
File.close(device)
first_line != @expected_first_line
{:error, _} ->
true
end
end)
if missing == [] do
Mix.shell().info("All #{length(files)} files have license headers.")
else
Mix.shell().error("Missing license header in #{length(missing)} file(s):")
Enum.each(missing, fn file ->
Mix.shell().error(" #{file}")
end)
Mix.raise("License check failed. Add the Apache 2.0 header to the files listed above.")
end
end
end
LICENSE +200 −0
@@ -1,1 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an in-depth
understanding of the text at http://www.apache.org/foundation/license-faq.html
Copyright 2026 Cole Christensen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
mix.exs +69 −2
@@ -1,6 +1,22 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.MixProject do
use Mix.Project
@source_url "https://github.com/notifd/ex_git_objectstore"
def project do
[
app: :ex_git_objectstore,
@@ -8,6 +24,55 @@
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
package: package(),
description: "Pure Elixir git object store with pluggable storage backends",
source_url: @source_url,
homepage_url: @source_url,
docs: docs(),
dialyzer: [
plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
]
]
end
defp package do
[
licenses: ["Apache-2.0"],
links: %{"GitHub" => @source_url},
files: ~w(lib LICENSE NOTICE README.md CHANGELOG.md mix.exs .formatter.exs)
]
end
defp docs do
[
main: "readme",
extras: ["README.md", "CHANGELOG.md", "LICENSE", "NOTICE"],
source_url: @source_url,
groups_for_modules: [
"Object Types": [
ExGitObjectstore.Object.Blob,
ExGitObjectstore.Object.Commit,
ExGitObjectstore.Object.Tree,
ExGitObjectstore.Object.Tag
],
"Storage Backends": [
ExGitObjectstore.Storage,
ExGitObjectstore.Storage.Filesystem,
ExGitObjectstore.Storage.Memory,
ExGitObjectstore.Storage.S3
],
"Pack Format": [
ExGitObjectstore.Pack.Reader,
ExGitObjectstore.Pack.Writer,
ExGitObjectstore.Pack.Index,
ExGitObjectstore.Pack.Delta
],
"Git Protocol": [
ExGitObjectstore.Protocol.PktLine,
ExGitObjectstore.Protocol.UploadPack,
elixirc_paths: elixirc_paths(Mix.env())
ExGitObjectstore.Protocol.ReceivePack
]
]
]
end
@@ -27,7 +92,9 @@
{:ex_aws_s3, "~> 2.5"},
{:sweet_xml, "~> 0.7"},
{:hackney, "~> 1.18"},
{:jason, "~> 1.4"}
{:jason, "~> 1.4"},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
]
end
end
mix.lock +8 −0
@@ -1,13 +1,21 @@
%{
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"},
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
NOTICE +4 −0
@@ -1,0 +1,4 @@
ExGitObjectstore
Copyright 2026 Cole Christensen
This product includes software developed by Cole Christensen.
README.md +120 −6
@@ -1,11 +1,27 @@
# ExGitObjectstore
**TODO: Add description**
Pure Elixir git object store with pluggable storage backends.
Read, write, and manipulate git objects (blobs, trees, commits, tags), refs, and
packfiles without requiring libgit2, the git CLI, or any NIF. All git data is
stored through a pluggable storage backend — use the local filesystem, S3, or
in-memory storage.
## Features
- **Git objects** — encode, decode, hash, read, and write blobs, trees, commits, and tags
- **Refs** — branches, tags, HEAD, compare-and-swap updates
- **Packfiles** — read and write `.pack` and `.idx` v2 files, delta resolution
- **Three-way merge** — recursive tree merge with conflict detection
- **Diff engine** — Myers diff algorithm with unified diff output and context hunks
- **Graph traversal** — commit log, merge base (LCA) finding
- **Git wire protocol** — pkt-line framing, upload-pack, receive-pack
- **Pluggable storage** — filesystem, S3 (any S3-compatible service), and in-memory backends
- **ETS caching** — LRU object cache with configurable size limits
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `ex_git_objectstore` to your list of dependencies in `mix.exs`:
Add `ex_git_objectstore` to your list of dependencies in `mix.exs`:
```elixir
def deps do
@@ -14,8 +30,106 @@
]
end
```
## Quick Start
```elixir
alias ExGitObjectstore.{Repo, Object}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
# Create a repo with in-memory storage
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.Memory,
storage_config: %{}
}
# Initialize and write objects
:ok = ExGitObjectstore.init(repo)
{:ok, blob_sha} = Object.write(repo, Blob.from_content("Hello, world!\n"))
{:ok, tree_sha} = Object.write(repo, Tree.new([
%{mode: "100644", name: "README.md", sha: blob_sha}
]))
{:ok, commit_sha} = Object.write(repo, %Commit{
tree: tree_sha,
parents: [],
author: "Alice <alice@example.com> 1700000000 +0000",
committer: "Alice <alice@example.com> 1700000000 +0000",
message: "Initial commit"
})
:ok = ExGitObjectstore.create_branch(repo, "main", commit_sha)
# Read it back
{:ok, {^commit_sha, commit}} = ExGitObjectstore.commit(repo, "main")
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
{:ok, tree} = ExGitObjectstore.tree(repo, "main")
{:ok, content} = ExGitObjectstore.blob(repo, blob_sha)
```
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/ex_git_objectstore>.
## Storage Backends
### Filesystem
```elixir
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.Filesystem,
storage_config: %{root: "/var/git/repos/my-repo"}
}
```
Uses the standard git loose object layout with atomic writes (temp file + rename)
and lock-file compare-and-swap for ref updates.
### S3
```elixir
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.S3,
storage_config: %{
bucket: "my-git-bucket",
prefix: "repos/my-repo",
ex_aws_config: [
access_key_id: "...",
secret_access_key: "...",
region: "us-east-1"
]
}
}
```
Works with AWS S3, MinIO, or any S3-compatible service. Handles pagination
for repositories with many objects.
### Memory
```elixir
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.Memory,
storage_config: %{}
}
```
Stores everything in an ETS table. Useful for testing and ephemeral operations.
## Known Limitations
This is v0.1.0 — a functional foundation with some protocol features still in progress:
- **REF_DELTA** — pack reader resolves OFS_DELTA but not REF_DELTA (thin packs from `git fetch` may fail)
- **ofs-delta generation** — pack writer stores full objects only, no delta compression
- **multi_ack_detailed** — upload-pack uses NAK-only negotiation
- **side-band-64k** — receive-pack doesn't use side-band for status reporting
These are tracked as TODOs in the source — the scaffolding is in place for implementation.
## License
Copyright 2026 Cole Christensen
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details.
test/ex_git_objectstore/cache/ets_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Cache.ETSTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/diff/diff_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Diff.DiffTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/diff/myers_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Diff.MyersTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/integration/git_repo_test.exs +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Integration.GitRepoTest do
@moduledoc """
Integration tests that create real git repos with the git CLI,
test/ex_git_objectstore/integration/packed_repo_test.exs +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Integration.PackedRepoTest do
@moduledoc """
Integration test: create a repo, `git gc` it, then read all objects
test/ex_git_objectstore/merge_test.exs +16 −2
@@ -1,6 +1,20 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.MergeTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Merge, Object, Repo}
alias ExGitObjectstore.{Merge, Object}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
@@ -24,7 +38,7 @@
end
# Helper to create a commit pointing at a tree with given parents
defp write_commit(repo, tree_sha, parents, message \\ "test commit") do
defp write_commit(repo, tree_sha, parents, message) do
commit = %Commit{
tree: tree_sha,
parents: parents,
test/ex_git_objectstore/object/blob_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.BlobTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/object/commit_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.CommitTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/object/git_interop_test.exs +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.GitInteropTest do
@moduledoc """
Tests that verify our object encoding matches the real git CLI.
test/ex_git_objectstore/object/tag_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.TagTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/object/tree_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Object.TreeTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/object_resolver_test.exs +250 −0
@@ -1,0 +1,250 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.ObjectResolverTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, ObjectResolver, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Test.RepoHelper
defp write_blob_to_pack(repo, content) do
blob = Blob.from_content(content)
sha = Object.hash(blob)
raw_content = Object.encode_content_only(blob)
{pack_data, idx_data, pack_sha} =
Writer.generate_with_index([{:blob, raw_content, sha}])
:ok = Repo.storage_call(repo, :put_pack, [pack_sha, pack_data, idx_data])
sha
end
defp write_tree_to_pack(repo, entries) do
tree = Tree.new(entries)
sha = Object.hash(tree)
raw_content = Object.encode_content_only(tree)
{pack_data, idx_data, pack_sha} =
Writer.generate_with_index([{:tree, raw_content, sha}])
:ok = Repo.storage_call(repo, :put_pack, [pack_sha, pack_data, idx_data])
sha
end
defp write_commit_to_pack(repo, tree_sha, message) do
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1234567890 +0000",
committer: "Test <test@test.com> 1234567890 +0000",
message: message
}
sha = Object.hash(commit)
raw_content = Object.encode_content_only(commit)
{pack_data, idx_data, pack_sha} =
Writer.generate_with_index([{:commit, raw_content, sha}])
:ok = Repo.storage_call(repo, :put_pack, [pack_sha, pack_data, idx_data])
{sha, commit}
end
describe "reading a loose object by SHA" do
test "reads a blob stored as a loose object" do
repo = RepoHelper.memory_repo()
blob = Blob.from_content("hello resolver")
{:ok, sha} = Object.write(repo, blob)
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert %Blob{content: "hello resolver"} = decoded
end
test "reads a tree stored as a loose object" do
repo = RepoHelper.memory_repo()
blob = Blob.from_content("file data")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "readme.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
assert {:ok, decoded} = ObjectResolver.read(repo, tree_sha)
assert %Tree{entries: [entry]} = decoded
assert entry.name == "readme.txt"
assert entry.sha == blob_sha
end
test "reads a commit stored as a loose object" do
repo = RepoHelper.memory_repo()
tree_sha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1234567890 +0000",
committer: "Test <test@test.com> 1234567890 +0000",
message: "initial commit\n"
}
{:ok, sha} = Object.write(repo, commit)
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert %Commit{} = decoded
assert decoded.tree == tree_sha
assert decoded.message == "initial commit\n"
end
end
describe "reading an object from a pack" do
test "reads a blob from a packfile" do
repo = RepoHelper.memory_repo()
sha = write_blob_to_pack(repo, "packed content")
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert %Blob{content: "packed content"} = decoded
end
test "reads a tree from a packfile" do
repo = RepoHelper.memory_repo()
# We need a valid 40-char sha for the tree entry
blob_sha = String.duplicate("f", 40)
sha = write_tree_to_pack(repo, [%{mode: "100644", name: "packed.txt", sha: blob_sha}])
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert %Tree{entries: [entry]} = decoded
assert entry.name == "packed.txt"
assert entry.sha == blob_sha
end
test "reads a commit from a packfile" do
repo = RepoHelper.memory_repo()
tree_sha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
{sha, _commit} = write_commit_to_pack(repo, tree_sha, "packed commit\n")
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert %Commit{} = decoded
assert decoded.tree == tree_sha
assert decoded.message == "packed commit\n"
end
end
describe "fallback from loose miss to pack hit" do
test "finds object in pack when not present as loose" do
repo = RepoHelper.memory_repo()
# Object is NOT written as loose, only packed
sha = write_blob_to_pack(repo, "only in pack")
# Verify it is not in loose storage
assert {:error, :not_found} = Object.read(repo, sha)
# ObjectResolver should find it in the pack
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert %Blob{content: "only in pack"} = decoded
end
test "prefers loose object over packed version" do
repo = RepoHelper.memory_repo()
# Write as loose
blob = Blob.from_content("loose version")
{:ok, sha} = Object.write(repo, blob)
# Also create a pack with an object (different content would have different sha,
# so we verify the loose path is taken by checking the read succeeds)
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert %Blob{content: "loose version"} = decoded
end
end
describe "object not found in either" do
test "returns not_found when object is not in loose or pack storage" do
repo = RepoHelper.memory_repo()
missing_sha = "deadbeef" <> String.duplicate("0", 32)
assert {:error, :not_found} = ObjectResolver.read(repo, missing_sha)
end
test "returns not_found when there are packs but the SHA is not in any of them" do
repo = RepoHelper.memory_repo()
# Create a pack with a different object
_sha = write_blob_to_pack(repo, "something else")
# Try to read a different SHA
missing_sha = "deadbeef" <> String.duplicate("0", 32)
assert {:error, :not_found} = ObjectResolver.read(repo, missing_sha)
end
end
describe "reading different object types from packs" do
test "blob round-trip through pack" do
repo = RepoHelper.memory_repo()
sha = write_blob_to_pack(repo, "blob content here")
{:ok, result} = ObjectResolver.read(repo, sha)
assert %Blob{content: "blob content here"} = result
end
test "tree round-trip through pack" do
repo = RepoHelper.memory_repo()
blob_sha = String.duplicate("d", 40)
sha =
write_tree_to_pack(repo, [
%{mode: "100644", name: "a.txt", sha: blob_sha},
%{mode: "100755", name: "b.sh", sha: blob_sha}
])
{:ok, result} = ObjectResolver.read(repo, sha)
assert %Tree{entries: entries} = result
assert length(entries) == 2
names = Enum.map(entries, & &1.name)
assert "a.txt" in names
assert "b.sh" in names
end
test "commit round-trip through pack" do
repo = RepoHelper.memory_repo()
tree_sha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
{sha, _commit} = write_commit_to_pack(repo, tree_sha, "commit in pack\n")
{:ok, result} = ObjectResolver.read(repo, sha)
assert %Commit{} = result
assert result.tree == tree_sha
assert result.message == "commit in pack\n"
assert result.author == "Test <test@test.com> 1234567890 +0000"
end
end
describe "multiple packs" do
test "finds objects across multiple pack files" do
repo = RepoHelper.memory_repo()
# Write two objects into two separate packs
sha1 = write_blob_to_pack(repo, "pack one content")
sha2 = write_blob_to_pack(repo, "pack two content")
# Verify both can be resolved
{:ok, result1} = ObjectResolver.read(repo, sha1)
assert %Blob{content: "pack one content"} = result1
{:ok, result2} = ObjectResolver.read(repo, sha2)
assert %Blob{content: "pack two content"} = result2
end
end
end
test/ex_git_objectstore/pack/delta_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.DeltaTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/pack/index_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.IndexTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/pack/reader_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.ReaderTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/pack/writer_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Pack.WriterTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/protocol/pkt_line_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.PktLineTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/protocol/receive_pack_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.ReceivePackTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/protocol/upload_pack_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.UploadPackTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/ref_test.exs +350 −0
@@ -1,0 +1,350 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.RefTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.RepoHelper
@valid_sha String.duplicate("a", 40)
@valid_sha2 String.duplicate("b", 40)
@valid_sha3 String.duplicate("c", 40)
describe "validate_sha/1" do
test "accepts a valid 40-char lowercase hex SHA" do
assert :ok = Ref.validate_sha(@valid_sha)
end
test "accepts sha with mixed hex digits" do
sha = "0123456789abcdef0123456789abcdef01234567"
assert :ok = Ref.validate_sha(sha)
end
test "rejects a SHA that is too short" do
assert {:error, {:invalid_sha, "deadbeef"}} = Ref.validate_sha("deadbeef")
end
test "rejects a SHA that is too long" do
sha = String.duplicate("a", 41)
assert {:error, {:invalid_sha, ^sha}} = Ref.validate_sha(sha)
end
test "rejects a SHA with non-hex characters" do
sha = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
assert {:error, {:invalid_sha, ^sha}} = Ref.validate_sha(sha)
end
test "rejects a SHA with uppercase hex characters" do
sha = String.duplicate("A", 40)
assert {:error, {:invalid_sha, ^sha}} = Ref.validate_sha(sha)
end
test "rejects an empty string" do
assert {:error, {:invalid_sha, ""}} = Ref.validate_sha("")
end
test "rejects sha with spaces" do
sha = String.duplicate("a", 39) <> " "
assert {:error, {:invalid_sha, ^sha}} = Ref.validate_sha(sha)
end
end
describe "validate_ref_name/1" do
test "accepts a valid ref name like refs/heads/main" do
assert :ok = Ref.validate_ref_name("refs/heads/main")
end
test "accepts a valid tag ref name" do
assert :ok = Ref.validate_ref_name("refs/tags/v1.0.0")
end
test "accepts a single-segment name" do
assert :ok = Ref.validate_ref_name("main")
end
test "rejects ref name containing .." do
assert {:error, {:invalid_ref_name, "refs/heads/foo..bar", :dotdot}} =
Ref.validate_ref_name("refs/heads/foo..bar")
end
test "rejects ref name with .. at the start" do
assert {:error, {:invalid_ref_name, "../main", :dotdot}} =
Ref.validate_ref_name("../main")
end
test "rejects ref name ending with .lock" do
assert {:error, {:invalid_ref_name, "refs/heads/main.lock", :lock_suffix}} =
Ref.validate_ref_name("refs/heads/main.lock")
end
test "rejects ref name containing //" do
assert {:error, {:invalid_ref_name, "refs//heads/main", :double_slash}} =
Ref.validate_ref_name("refs//heads/main")
end
test "rejects ref name containing null byte" do
name = "refs/heads/ma\0in"
assert {:error, {:invalid_ref_name, ^name, :null_byte}} =
Ref.validate_ref_name(name)
end
test "rejects ref name with trailing slash" do
assert {:error, {:invalid_ref_name, "refs/heads/main/", :trailing_slash}} =
Ref.validate_ref_name("refs/heads/main/")
end
test "rejects ref name with leading slash" do
assert {:error, {:invalid_ref_name, "/refs/heads/main", :leading_slash}} =
Ref.validate_ref_name("/refs/heads/main")
end
test "rejects empty ref name" do
assert {:error, {:invalid_ref_name, "", :empty}} =
Ref.validate_ref_name("")
end
end
describe "resolve/2" do
test "returns a 40-char hex SHA directly without storage lookup" do
repo = RepoHelper.memory_repo()
assert {:ok, @valid_sha} = Ref.resolve(repo, @valid_sha)
end
test "resolves a branch name via refs/heads/<name>" do
repo = RepoHelper.memory_repo()
Ref.put(repo, "refs/heads/main", @valid_sha, nil)
assert {:ok, @valid_sha} = Ref.resolve(repo, "main")
end
test "resolves a tag name via refs/tags/<name>" do
repo = RepoHelper.memory_repo()
Ref.put(repo, "refs/tags/v1", @valid_sha, nil)
assert {:ok, @valid_sha} = Ref.resolve(repo, "v1")
end
test "branch takes priority over tag when both exist with same name" do
repo = RepoHelper.memory_repo()
Ref.put(repo, "refs/heads/release", @valid_sha, nil)
Ref.put(repo, "refs/tags/release", @valid_sha2, nil)
# refs/heads/ is tried first
assert {:ok, @valid_sha} = Ref.resolve(repo, "release")
end
test "resolves HEAD when it points to a symbolic ref" do
repo = RepoHelper.memory_repo()
Ref.put_head(repo, "ref: refs/heads/main")
Ref.put(repo, "refs/heads/main", @valid_sha, nil)
assert {:ok, @valid_sha} = Ref.resolve(repo, "HEAD")
end
test "resolves HEAD when it is a detached SHA" do
repo = RepoHelper.memory_repo()
Ref.put_head(repo, @valid_sha)
assert {:ok, @valid_sha} = Ref.resolve(repo, "HEAD")
end
test "returns error for HEAD when not set" do
repo = RepoHelper.memory_repo()
assert {:error, :not_found} = Ref.resolve(repo, "HEAD")
end
test "returns error when ref does not exist" do
repo = RepoHelper.memory_repo()
assert {:error, :ref_not_found} = Ref.resolve(repo, "nonexistent")
end
test "resolves a full ref path directly" do
repo = RepoHelper.memory_repo()
Ref.put(repo, "refs/remotes/origin/main", @valid_sha, nil)
assert {:ok, @valid_sha} = Ref.resolve(repo, "refs/remotes/origin/main")
end
end
describe "put/4" do
test "creates a new ref with old_sha=nil (no CAS)" do
repo = RepoHelper.memory_repo()
assert :ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
assert {:ok, @valid_sha} = Ref.get(repo, "refs/heads/main")
end
test "CAS update succeeds when old_sha matches current value" do
repo = RepoHelper.memory_repo()
:ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
assert :ok = Ref.put(repo, "refs/heads/main", @valid_sha2, @valid_sha)
assert {:ok, @valid_sha2} = Ref.get(repo, "refs/heads/main")
end
test "CAS update fails when old_sha does not match current value" do
repo = RepoHelper.memory_repo()
:ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
assert {:error, :cas_failed} =
Ref.put(repo, "refs/heads/main", @valid_sha3, @valid_sha2)
# original value is unchanged
assert {:ok, @valid_sha} = Ref.get(repo, "refs/heads/main")
end
test "rejects put with an invalid new_sha" do
repo = RepoHelper.memory_repo()
assert {:error, {:invalid_sha, "bad"}} =
Ref.put(repo, "refs/heads/main", "bad", nil)
end
test "rejects put with an invalid old_sha" do
repo = RepoHelper.memory_repo()
assert {:error, {:invalid_sha, "bad"}} =
Ref.put(repo, "refs/heads/main", @valid_sha, "bad")
end
test "rejects put with an invalid ref name" do
repo = RepoHelper.memory_repo()
assert {:error, {:invalid_ref_name, "refs/heads/main.lock", :lock_suffix}} =
Ref.put(repo, "refs/heads/main.lock", @valid_sha, nil)
end
test "validates ref name before sha when both are invalid" do
repo = RepoHelper.memory_repo()
# Invalid sha is checked first because of `with` chain
assert {:error, {:invalid_sha, "bad"}} =
Ref.put(repo, "refs/heads/main.lock", "bad", nil)
end
end
describe "get/2" do
test "returns the SHA for an existing ref" do
repo = RepoHelper.memory_repo()
:ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
assert {:ok, @valid_sha} = Ref.get(repo, "refs/heads/main")
end
test "returns error for a non-existent ref" do
repo = RepoHelper.memory_repo()
assert {:error, :not_found} = Ref.get(repo, "refs/heads/nope")
end
end
describe "list/2" do
test "lists all refs under a prefix" do
repo = RepoHelper.memory_repo()
:ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
:ok = Ref.put(repo, "refs/heads/dev", @valid_sha2, nil)
:ok = Ref.put(repo, "refs/tags/v1", @valid_sha3, nil)
{:ok, heads} = Ref.list(repo, "refs/heads/")
assert length(heads) == 2
ref_names = Enum.map(heads, &elem(&1, 0))
assert "refs/heads/main" in ref_names
assert "refs/heads/dev" in ref_names
# tags prefix returns only tags
{:ok, tags} = Ref.list(repo, "refs/tags/")
assert length(tags) == 1
assert [{"refs/tags/v1", @valid_sha3}] = tags
end
test "returns empty list when no refs match the prefix" do
repo = RepoHelper.memory_repo()
:ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
{:ok, tags} = Ref.list(repo, "refs/tags/")
assert tags == []
end
end
describe "delete/2" do
test "deletes an existing ref" do
repo = RepoHelper.memory_repo()
:ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
assert :ok = Ref.delete(repo, "refs/heads/main")
assert {:error, :not_found} = Ref.get(repo, "refs/heads/main")
end
test "deleting a non-existent ref succeeds silently" do
repo = RepoHelper.memory_repo()
assert :ok = Ref.delete(repo, "refs/heads/nope")
end
test "delete does not affect other refs" do
repo = RepoHelper.memory_repo()
:ok = Ref.put(repo, "refs/heads/main", @valid_sha, nil)
:ok = Ref.put(repo, "refs/heads/dev", @valid_sha2, nil)
:ok = Ref.delete(repo, "refs/heads/main")
assert {:error, :not_found} = Ref.get(repo, "refs/heads/main")
assert {:ok, @valid_sha2} = Ref.get(repo, "refs/heads/dev")
end
end
describe "get_head/1" do
test "returns the HEAD value when set" do
repo = RepoHelper.memory_repo()
:ok = Ref.put_head(repo, "ref: refs/heads/main")
assert {:ok, "ref: refs/heads/main"} = Ref.get_head(repo)
end
test "returns error when HEAD is not set" do
repo = RepoHelper.memory_repo()
assert {:error, :not_found} = Ref.get_head(repo)
end
end
describe "put_head/2" do
test "sets HEAD to a symbolic ref" do
repo = RepoHelper.memory_repo()
assert :ok = Ref.put_head(repo, "ref: refs/heads/main")
assert {:ok, "ref: refs/heads/main"} = Ref.get_head(repo)
end
test "sets HEAD to a detached SHA" do
repo = RepoHelper.memory_repo()
assert :ok = Ref.put_head(repo, @valid_sha)
assert {:ok, @valid_sha} = Ref.get_head(repo)
end
test "overwrites a previous HEAD value" do
repo = RepoHelper.memory_repo()
:ok = Ref.put_head(repo, "ref: refs/heads/main")
:ok = Ref.put_head(repo, "ref: refs/heads/dev")
assert {:ok, "ref: refs/heads/dev"} = Ref.get_head(repo)
end
end
end
test/ex_git_objectstore/storage/filesystem_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Storage.FilesystemTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/storage/memory_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Storage.MemoryTest do
use ExUnit.Case, async: true
test/ex_git_objectstore/storage/s3_test.exs +407 −0
@@ -1,0 +1,407 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Storage.S3Test do
use ExUnit.Case, async: false
@moduletag :s3
alias ExGitObjectstore.Storage.S3
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
@minio_config %{
bucket: "test-bucket",
ex_aws_config: [
access_key_id: "minioadmin",
secret_access_key: "minioadmin",
region: "us-east-1",
host: "localhost",
port: 9000,
scheme: "http://",
s3_scheme: "http://",
s3_host: "localhost",
s3_port: 9000
]
}
@minio_available (case :gen_tcp.connect(~c"localhost", 9000, [], 1_000) do
{:ok, socket} ->
:gen_tcp.close(socket)
true
{:error, _} ->
false
end)
if !@minio_available do
@moduletag :skip
end
setup_all do
if @minio_available do
ensure_bucket()
end
:ok
end
setup do
unique_id = "test-#{:erlang.unique_integer([:positive])}"
config = @minio_config
repo = Repo.new(unique_id, storage: {S3, config})
on_exit(fn ->
if @minio_available do
cleanup_prefix(config, "repos/#{unique_id}")
end
end)
%{repo: repo, config: config, unique_id: unique_id}
end
describe "object storage" do
test "write and read a blob", %{repo: repo} do
blob = Blob.from_content("hello world")
assert {:ok, sha} = Object.write(repo, blob)
assert String.length(sha) == 40
assert {:ok, decoded} = Object.read(repo, sha)
assert %Blob{content: "hello world"} = decoded
end
test "read non-existent object returns error", %{repo: repo} do
assert {:error, :not_found} =
Object.read(repo, "deadbeef" <> String.duplicate("0", 32))
end
test "write and read a tree", %{repo: repo} do
blob = Blob.from_content("file content")
{: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)
{:ok, decoded} = Object.read(repo, tree_sha)
assert %Tree{entries: [entry]} = decoded
assert entry.name == "file.txt"
assert entry.sha == blob_sha
end
test "write and read a commit", %{repo: repo} do
tree_sha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1234567890 +0000",
committer: "Test <test@test.com> 1234567890 +0000",
message: "init\n"
}
{:ok, sha} = Object.write(repo, commit)
{:ok, decoded} = Object.read(repo, sha)
assert %Commit{} = decoded
assert decoded.tree == tree_sha
assert decoded.message == "init\n"
end
test "object_exists? returns correct values", %{repo: repo} do
blob = Blob.from_content("exists 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)
assert :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 "get non-existent ref returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_ref, ["refs/heads/nope"])
end
test "CAS put_ref succeeds with matching old_sha", %{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])
assert :ok = Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, sha1])
assert {:ok, ^sha2} = Repo.storage_call(repo, :get_ref, ["refs/heads/main"])
end
test "CAS put_ref fails with mismatched old_sha", %{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 {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha2, wrong])
end
test "CAS put_ref fails when ref does not exist", %{repo: repo} do
sha1 = String.duplicate("a", 40)
sha2 = String.duplicate("b", 40)
assert {:error, :cas_failed} =
Repo.storage_call(repo, :put_ref, ["refs/heads/main", sha1, sha2])
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 under prefix", %{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 storage" 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
test "get HEAD when not set returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_head, [])
end
end
describe "pack storage" do
test "put and get pack", %{repo: repo} do
pack_data = "PACK" <> <<0, 0, 0, 2>> <> "fake pack data"
idx_data = "fake index data"
: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"])
end
test "get non-existent pack returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack, ["nonexistent"])
end
test "get non-existent pack index returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack_index, ["nonexistent"])
end
test "list packs", %{repo: repo} do
:ok = Repo.storage_call(repo, :put_pack, ["abc123", "pack1", "idx1"])
:ok = Repo.storage_call(repo, :put_pack, ["def456", "pack2", "idx2"])
{:ok, packs} = Repo.storage_call(repo, :list_packs, [])
assert length(packs) == 2
assert "abc123" in packs
assert "def456" in packs
end
test "list packs returns empty when none exist", %{repo: repo} do
{:ok, packs} = Repo.storage_call(repo, :list_packs, [])
assert packs == []
end
test "stream_pack returns enumerable", %{repo: repo} do
pack_data = "test pack content"
:ok = Repo.storage_call(repo, :put_pack, ["abc123", pack_data, "idx"])
{:ok, stream} = Repo.storage_call(repo, :stream_pack, ["abc123"])
assert IO.iodata_to_binary(Enum.to_list(stream)) == pack_data
end
test "stream_pack returns error for non-existent pack", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :stream_pack, ["nonexistent"])
end
end
describe "S3 list pagination" do
# S3 ListObjectsV2 returns at most 1000 keys by default.
# We test that our s3_list_all implementation correctly handles
# continuation tokens by writing enough objects to trigger pagination.
# MinIO respects the same pagination semantics as AWS S3.
@tag timeout: 120_000
test "list_packs handles paginated results with many objects", %{repo: repo} do
# Write enough packs to exceed the default S3 page size (1000 keys).
# Each put_pack creates 2 keys (.pack + .idx), so 501 packs = 1002 keys
# under the pack prefix. list_packs filters to .pack only, but the
# underlying s3_list sees all 1002 keys which triggers pagination.
pack_count = 501
pack_shas =
for i <- 1..pack_count do
sha = :crypto.hash(:sha, "pack-#{i}") |> Base.encode16(case: :lower)
:ok = Repo.storage_call(repo, :put_pack, [sha, "data-#{i}", "idx-#{i}"])
sha
end
{:ok, listed_packs} = Repo.storage_call(repo, :list_packs, [])
assert length(listed_packs) == pack_count
# Verify all expected pack SHAs are present
listed_set = MapSet.new(listed_packs)
for sha <- pack_shas do
assert sha in listed_set,
"Expected pack #{sha} to be in list_packs result"
end
end
@tag timeout: 120_000
test "list_refs handles paginated results with many refs", %{repo: repo} do
# Write more than 1000 refs under refs/heads/ to trigger pagination
ref_count = 1001
expected_refs =
for i <- 1..ref_count do
sha = :crypto.hash(:sha, "ref-#{i}") |> Base.encode16(case: :lower)
padded = String.pad_leading("#{i}", 5, "0")
ref_name = "refs/heads/branch-#{padded}"
:ok = Repo.storage_call(repo, :put_ref, [ref_name, sha, nil])
{ref_name, sha}
end
{:ok, listed_refs} = Repo.storage_call(repo, :list_refs, ["refs/heads/"])
assert length(listed_refs) == ref_count
listed_set = MapSet.new(listed_refs)
for {ref_name, sha} <- expected_refs do
assert {ref_name, sha} in listed_set,
"Expected ref #{ref_name} -> #{sha} to be in list_refs result"
end
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
# -- Helpers --
defp ensure_bucket do
config = @minio_config
case ExAws.request(
ExAws.S3.head_bucket(config.bucket),
config.ex_aws_config
) do
{:ok, _} ->
:ok
{:error, _} ->
{:ok, _} =
ExAws.request(
ExAws.S3.put_bucket(config.bucket, config.ex_aws_config[:region]),
config.ex_aws_config
)
end
end
defp cleanup_prefix(config, prefix) do
# List and delete all objects under this prefix
case list_all_keys(config, prefix, nil, []) do
{:ok, []} ->
:ok
{:ok, keys} ->
# Delete in batches of 1000 (S3 multi-delete limit)
keys
|> Enum.chunk_every(1000)
|> Enum.each(fn batch ->
op = ExAws.S3.delete_all_objects(config.bucket, batch)
ExAws.request(op, config.ex_aws_config)
end)
{:error, _} ->
:ok
end
end
defp list_all_keys(config, prefix, continuation_token, acc) do
opts =
[prefix: prefix] ++
if(continuation_token, do: [continuation_token: continuation_token], else: [])
op = ExAws.S3.list_objects_v2(config.bucket, opts)
case ExAws.request(op, config.ex_aws_config) do
{:ok, %{body: %{contents: contents, is_truncated: "true", next_continuation_token: token}}} ->
keys = Enum.map(contents, & &1.key)
list_all_keys(config, prefix, token, acc ++ keys)
{:ok, %{body: %{contents: contents}}} ->
keys = Enum.map(contents, & &1.key)
{:ok, acc ++ keys}
{:ok, %{body: _}} ->
{:ok, acc}
{:error, reason} ->
{:error, reason}
end
end
end
test/ex_git_objectstore/walk/merge_base_test.exs +15 −1
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Walk.MergeBaseTest do
use ExUnit.Case, async: true
@@ -20,7 +34,7 @@
sha
end
defp write_commit(repo, tree_sha, parents, timestamp \\ 1_700_000_000) do
defp write_commit(repo, tree_sha, parents, timestamp) do
commit = %Commit{
tree: tree_sha,
parents: parents,
test/ex_git_objectstore/walk_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.WalkTest do
use ExUnit.Case, async: true
test/ex_git_objectstore_test.exs +14 −0
@@ -1,2 +1,16 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstoreTest do
use ExUnit.Case, async: true
test/support/repo_helper.ex +14 −0
@@ -1,3 +1,17 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Test.RepoHelper do
@moduledoc """
Test helper for creating in-memory repos.
test/test_helper.exs +15 −1
@@ -1,1 +1,15 @@
ExUnit.start()
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
ExUnit.start(exclude: [:s3])