|
|
lib/ex_git_objectstore/cache/ets.ex
|
+56
−26
|
@@ -5,11 +5,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.
Config: %{table: :my_cache_table, lru_table: :my_lru_table, max_entries: 10_000} The caller is responsible for creating the ETS table before use: :ets.new(:my_cache, [:set, :public, :named_table]) """
@doc """ @@ -27,18 +27,40 @@ write_concurrency: true ])
lru_table = :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. """ %{table: table, max_entries: max_entries} @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 +72,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, lru_table, div(max_entries, 10)) evict_oldest(table, div(max_entries, 10)) end
:ets.insert(table, {{repo_id, key}, value, System.monotonic_time()}) ts = 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 +99,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 +111,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 using the ordered LRU index — O(N) not O(total) defp evict_oldest(table, lru_table, count) do # 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) do_evict(table, lru_table, count, :ets.first(lru_table)) end |> Enum.take(count)
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
|
+34
−24
|
@@ -236,7 +236,16 @@ end
defp group_changes(indexed, context) do 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 (del/add) and expand with context
# 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 +253,43 @@ if current == [] do {groups, []} else {groups, [item | current]} {groups, current ++ [item]} end
_ -> {groups, [item | current]} {groups, current ++ [item]} end end)
all_groups = if current_group != [], do: groups ++ [current_group], else: groups all_groups = if current_group != [] do [Enum.reverse(current_group) | groups] else groups end |> Enum.reverse()
# Add context lines around each group # Add context lines around each group using pre-computed indices all_groups Enum.map(all_groups, fn group -> add_context(group, indexed, context) end) |> Enum.map(fn group -> add_context(group, indexed_vec, total, index_map, 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: []
case {List.first(group), List.last(group)} do defp add_context(group, indexed_vec, total, index_map, context) do first = List.first(group) last = List.last(group) {nil, _} -> return_if_empty.()
first_idx = Map.get(index_map, first) last_idx = Map.get(index_map, last) {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))
if first_idx == nil or last_idx == nil do group if first_idx == nil or last_idx == nil do else start_idx = max(0, first_idx - context) end_idx = min(length(all_indexed) - 1, last_idx + context) group else start_idx = max(0, first_idx - context) end_idx = min(total - 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/merge.ex
|
+23
−23
|
@@ -9,6 +9,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 +60,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 +75,12 @@ end
# Merge tree entries from all three sides defp merge_tree_entries(_repo, _base_tree, _ours_tree, _theirs_tree, _path_prefix, depth) defp merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, path_prefix) do 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, 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 +100,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, depth) do case merge_entry(repo, base_entry, ours_entry, theirs_entry, full_path) do {:keep, entry} -> {[entry | entries_acc], conflicts_acc} @@ -120,7 +127,7 @@ end
# Three-way merge logic for a single entry defp merge_entry(repo, base, ours, theirs, path, depth) do defp merge_entry(repo, base, ours, theirs, path) do cond do # All three identical — no change same_entry?(base, ours) and same_entry?(base, theirs) -> @@ -140,7 +147,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 +161,12 @@ end end
defp merge_subtrees(repo, base, ours, theirs, path, depth) do defp merge_subtrees(repo, base, ours, theirs, path) 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 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 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), {: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 +180,12 @@ end end
# Return the entry's SHA or write an empty tree for base-less subtree merges defp empty_tree_or_sha(repo, nil) do # Create an empty tree and return its SHA for base-less subtree merges defp empty_tree_sha(repo) do tree = Tree.new([]) Object.write(repo, Tree.new([])) end
defp empty_tree_or_sha(_repo, entry), do: {:ok, entry.sha} case Object.write(repo, tree) do {:ok, sha} -> 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
|
+33
−10
|
@@ -33,13 +33,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 object |> encode_raw() |> zlib_compress() {:ok, compressed} -> compressed {:error, reason} -> raise "zlib compression failed: #{inspect(reason)}" end 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 +93,15 @@ @spec write(Repo.t(), t()) :: {:ok, String.t()} | {:error, term()} def write(%Repo{} = repo, object) do sha = hash(object)
case safe_encode(object) do {:ok, compressed} -> compressed = encode(object) 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 +158,17 @@
defp zlib_compress(data) do z = :zlib.open()
try do :zlib.deflateInit(z) compressed = :zlib.deflate(z, data, :finish) :zlib.deflateEnd(z) {:ok, IO.iodata_to_binary(compressed)} :zlib.deflateInit(z) compressed = :zlib.deflate(z, data, :finish) :zlib.deflateEnd(z) :zlib.close(z) 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/tree.ex
|
+17
−1
|
@@ -21,6 +21,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 +44,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/pack/delta.ex
|
+10
−1
|
@@ -23,7 +23,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 +51,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
|
+44
−13
|
@@ -29,16 +29,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, @@ -166,16 +169,44 @@ 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) 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) |> 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 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
|
+17
−11
|
@@ -17,6 +17,7 @@ alias ExGitObjectstore.Pack.Delta
@pack_signature "PACK" @max_pack_objects 10_000_000
# Object types (3-bit) @obj_commit 1 @@ -39,8 +40,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 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 case parse_entries(rest, count, [], %{}) do {:ok, entries, _cache} -> {:ok, entries} {:error, _} = err -> err end end end @@ -271,5 +276,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 +288,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 +297,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 +307,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 +338,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
|
+13
−7
|
@@ -130,7 +130,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 +169,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>> = elem(Base.decode16(sha, case: :mixed), 1) <<first_byte, _rest::binary>> = Base.decode16!(sha, case: :lower) first_byte end) @@ -209,10 +209,16 @@
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) 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/receive_pack.ex
|
+16
−6
|
@@ -18,7 +18,11 @@ 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(), @@ -314,10 +318,16 @@
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) 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
|
+19
−3
|
@@ -16,7 +16,11 @@ 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(), @@ -83,14 +87,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 +108,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
|
+49
−1
|
@@ -18,7 +18,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 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]) Repo.storage_call(repo, :put_ref, [ref_path, new_sha, old_sha]) end end
@doc """ @@ -100,4 +104,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/storage/filesystem.ex
|
+91
−32
|
@@ -17,6 +17,8 @@
@behaviour ExGitObjectstore.Storage
@max_recursive_depth 20
# -- Object operations --
@impl true @@ -33,9 +35,7 @@ @impl true def put_object(config, prefix, sha, data) do path = object_path(config, prefix, sha) File.mkdir_p!(Path.dirname(path)) File.write!(path, data) :ok atomic_write(path, data) end
@impl true @@ -96,10 +96,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") File.mkdir_p!(Path.dirname(pack_p)) File.write!(pack_p, pack_data) File.write!(idx_p, idx_data) :ok
with :ok <- atomic_write(pack_p, pack_data), :ok <- atomic_write(idx_p, idx_data) do :ok end end
@impl true @@ -130,28 +131,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 # 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) # CAS: check current value case File.read(path) do {:ok, data} -> if String.trim(data) == old_sha do File.mkdir_p!(Path.dirname(path)) File.write!(path, new_sha <> "\n") :ok
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 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, :ref_locked} {:error, :enoent} -> {:error, :cas_failed}
{:error, reason} -> {:error, reason} end else File.mkdir_p!(Path.dirname(path)) File.write!(path, new_sha <> "\n") :ok {:error, reason} -> {:error, reason} end end @@ -214,20 +237,50 @@ @impl true def put_head(config, prefix, target) do path = Path.join([config.root, prefix, "HEAD"]) File.mkdir_p!(Path.dirname(path)) File.write!(path, target <> "\n") :ok atomic_write(path, target <> "\n") end
# -- Private --
defp object_path(config, prefix, sha) do <<dir::binary-size(2), rest::binary>> = sha safe_path(config.root, Path.join([prefix, "objects", dir, rest])) Path.join([config.root, 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}" end end Path.join([config.root, prefix, "objects", "pack", "pack-#{pack_sha}.#{ext}"])
# 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 @@ -277,7 +330,13 @@ |> Map.new() end
defp list_files_recursive(dir), do: list_files_recursive(dir, 0)
defp list_files_recursive(_dir, depth) when depth > @max_recursive_depth do {:ok, []} end
defp list_files_recursive(dir, depth) do defp list_files_recursive(dir) do case File.ls(dir) do {:ok, entries} -> files = @@ -285,7 +344,7 @@ path = Path.join(dir, entry)
if File.dir?(path) do case list_files_recursive(path, depth + 1) do case list_files_recursive(path) do {:ok, sub_files} -> sub_files _ -> [] end
|
|
|
lib/ex_git_objectstore/storage/memory.ex
|
+3
−4
|
@@ -119,16 +119,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
|
+15
−3
|
@@ -240,15 +240,27 @@ end
defp s3_list(config, prefix) do s3_list_all(config, prefix, nil, []) end
defp s3_list_all(config, prefix, continuation_token, acc) do opts = op = ExAws.S3.list_objects_v2(config.bucket, prefix: prefix) [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
|
+16
−6
|
@@ -8,6 +8,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 +59,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()) :: {:ok, String.t()} | {:error, term()} def merge_base(%Repo{} = repo, sha_a, sha_b) do @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 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, limit), ancestor_set(repo, sha_b, limit)} do case {ancestor_set(repo, sha_a), ancestor_set(repo, sha_b)} 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,7 +124,7 @@ ) end
defp parse_timestamp(author_or_committer) do defp parse_timestamp(author_or_committer) when is_binary(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) @@ -124,8 +132,10 @@ 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}
|
|
|
test/ex_git_objectstore/object_resolver_test.exs
|
+236
−0
|
@@ -1,0 +1,236 @@ 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/ref_test.exs
|
+336
−0
|
@@ -1,0 +1,336 @@ 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/s3_test.exs
|
+393
−0
|
@@ -1,0 +1,393 @@ 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/test_helper.exs
|
+1
−1
|
@@ -1,1 +1,1 @@ ExUnit.start() ExUnit.start(exclude: [:s3])
|