|
|
.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])
|