ref:abf04d1876f1909315c3cfa872c16f056cf95a9d

fix(protocol): preserve negotiation state in v2, handle submodule gitlinks, and support atomic tags

SHA: abf04d1876f1909315c3cfa872c16f056cf95a9d
Author: t <t@t.com>
Date: 2026-08-31 00:08
Parents: a7e0745
12 files changed +604 -74
Type
.anvil.yml +6 −0
@@ -32,6 +32,12 @@
export MIX_HOME=/workspace/.mix
mix format --check-formatted
depends_on: [deps]
- name: audit
run: |
set -e
export MIX_HOME=/workspace/.mix
mix hex.audit
depends_on: [deps]
- name: dialyzer
run: |
set -e
lib/ex_git_objectstore/merge/diff3.ex +20 −0
@@ -314,6 +314,26 @@
# [{:ins, x}, {:eq, y}] -> [{:eq, x}, {:ins, y}]. Each rewrite preserves both
# reconstructed sequences (x == y) and moves the edit one line later; the
# recursion cascades a group to the bottom of its run in a single pass.
defp slide_down([{:del, x} = d1, {:del, _} = d2 | rest]) do
case slide_down([d2 | rest]) do
[{:eq, y} | slid_rest] when x == y ->
[{:eq, x} | slide_down([d1 | slid_rest])]
other ->
[d1 | other]
end
end
defp slide_down([{:ins, x} = i1, {:ins, _} = i2 | rest]) do
case slide_down([i2 | rest]) do
[{:eq, y} | slid_rest] when x == y ->
[{:eq, x} | slide_down([i1 | slid_rest])]
other ->
[i1 | other]
end
end
defp slide_down([{:del, x}, {:eq, y} | rest]) when x == y do
[{:eq, x} | slide_down([{:del, y} | rest])]
end
lib/ex_git_objectstore/protocol/upload_pack.ex +4 −0
@@ -459,6 +459,10 @@
{objs ++ acc, vis}
end
defp collect_single_tree_entry(_repo, %{mode: "160000"}, acc, vis, _depth) do
{acc, vis}
end
defp collect_single_tree_entry(repo, %{sha: sha}, acc, vis, _depth) do
if MapSet.member?(vis, sha) do
{acc, vis}
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +125 −54
@@ -61,10 +61,22 @@
@type state :: %__MODULE__{
repo: Repo.t(),
phase: :command | :done,
buffer: binary()
buffer: binary(),
wants: [String.t()],
haves: [String.t()],
filter_spec: Filter.spec() | nil,
shallow_opts: map() | nil
}
defstruct [
:repo,
phase: :command,
buffer: <<>>,
wants: [],
haves: [],
defstruct [:repo, phase: :command, buffer: <<>>]
filter_spec: nil,
shallow_opts: nil
]
@doc """
Create a new v2 upload-pack state machine and generate the capability advertisement.
@@ -147,9 +159,9 @@
{:fetch, args} ->
Logger.info("UploadPackV2: processing fetch command")
{response, new_state} = handle_fetch(state, args)
{response, next_phase} = handle_fetch(state.repo, args)
Logger.info("UploadPackV2: fetch response #{byte_size(response)} bytes")
{response, %{state | phase: next_phase}}
{response, new_state}
{:error, :no_command} ->
# No command= line found — could be a bare flush or partial data.
@@ -171,13 +183,13 @@
{:fetch, args} ->
Logger.info("UploadPackV2: processing fetch command")
{new_state, info} = handle_fetch_stream(state, args, write_fn)
{next_phase, info} = handle_fetch_stream(state.repo, args, write_fn)
Logger.info(
"UploadPackV2: fetch streamed #{info.pack_bytes} pack bytes, #{info.objects} objects"
)
{:ok, new_state}
{:ok, %{state | phase: next_phase}}
{:error, :no_command} ->
{:ok, state}
@@ -394,19 +406,24 @@
# for the next round of
# negotiation
defp handle_fetch(state, args) do
defp handle_fetch(repo, args) do
case parse_filter_spec(args) do
{:ok, filter_spec} -> handle_fetch_parsed(repo, args, filter_spec)
{:ok, filter_spec} -> handle_fetch_parsed(state, args, filter_spec)
{:error, err_reply} -> {err_reply, %{state | phase: :done}}
{:error, err_reply} -> {err_reply, :done}
end
end
defp handle_fetch_parsed(state, args, filter_spec) do
defp handle_fetch_parsed(repo, args, filter_spec) do
wants = extract_shas(args, "want ")
haves = extract_shas(args, "have ")
req_wants = extract_shas(args, "want ")
req_haves = extract_shas(args, "have ")
wants = if req_wants == [], do: state.wants, else: Enum.uniq(state.wants ++ req_wants)
haves = Enum.uniq(state.haves ++ req_haves)
done? = Enum.any?(args, &(String.trim(&1) == "done"))
wait_for_done? = Enum.any?(args, &(String.trim(&1) == "wait-for-done"))
shallow_opts = parse_shallow_opts(args)
shallow_opts = parse_shallow_opts(args) || state.shallow_opts
effective_filter = filter_spec || state.filter_spec
# Shallow fetches are a single round: the client includes `deepen`
# / `shallow` args expecting shallow-info + packfile in the direct
@@ -416,31 +433,42 @@
Logger.info(
"UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves, " <>
"done=#{done?}, wait-for-done=#{wait_for_done?}, " <>
"shallow=#{shallow_opts != nil}, filter=#{inspect(filter_spec)}, " <>
"shallow=#{shallow_opts != nil}, filter=#{inspect(effective_filter)}, " <>
"send_packfile=#{send_packfile?}"
)
span_meta = %{
repo_id: repo.id,
repo_id: state.repo.id,
wants: length(wants),
haves: length(haves),
mode: fetch_mode(shallow_opts, filter_spec, wait_for_done?)
mode: fetch_mode(shallow_opts, effective_filter, wait_for_done?)
}
:telemetry.span([:ex_git_objectstore, :protocol, :fetch], span_meta, fn ->
{result, extra} =
do_handle_fetch(
state.repo,
repo,
wants,
haves,
args,
done?,
wait_for_done?,
shallow_opts,
filter_spec,
effective_filter,
send_packfile?
)
{response, next_phase} = result
new_state = %{
state
| phase: next_phase,
wants: wants,
haves: haves,
filter_spec: effective_filter,
shallow_opts: shallow_opts
}
{result, Map.merge(span_meta, extra)}
{{response, new_state}, Map.merge(span_meta, extra)}
end)
end
@@ -504,37 +532,42 @@
defp fetch_mode(_shallow, nil, _), do: :shallow
defp fetch_mode(_, _, _), do: :shallow_filtered
defp handle_fetch_stream(repo, args, write_fn) do
defp handle_fetch_stream(state, args, write_fn) do
case parse_filter_spec(args) do
{:ok, filter_spec} ->
handle_fetch_parsed_stream(repo, args, filter_spec, write_fn)
handle_fetch_parsed_stream(state, args, filter_spec, write_fn)
{:error, err_reply} ->
write_fn.(err_reply)
{:done, %{pack_bytes: 0, objects: 0}}
{%{state | phase: :done}, %{pack_bytes: 0, objects: 0}}
end
end
defp handle_fetch_parsed_stream(state, args, filter_spec, write_fn) do
req_wants = extract_shas(args, "want ")
defp handle_fetch_parsed_stream(repo, args, filter_spec, write_fn) do
wants = extract_shas(args, "want ")
haves = extract_shas(args, "have ")
req_haves = extract_shas(args, "have ")
wants = if req_wants == [], do: state.wants, else: Enum.uniq(state.wants ++ req_wants)
haves = Enum.uniq(state.haves ++ req_haves)
done? = Enum.any?(args, &(String.trim(&1) == "done"))
wait_for_done? = Enum.any?(args, &(String.trim(&1) == "wait-for-done"))
shallow_opts = parse_shallow_opts(args)
shallow_opts = parse_shallow_opts(args) || state.shallow_opts
effective_filter = filter_spec || state.filter_spec
send_packfile? = done? or (shallow_opts != nil and not wait_for_done?)
Logger.info(
"UploadPackV2.handle_fetch: #{length(wants)} wants, #{length(haves)} haves, " <>
"done=#{done?}, wait-for-done=#{wait_for_done?}, " <>
"shallow=#{shallow_opts != nil}, filter=#{inspect(filter_spec)}, " <>
"shallow=#{shallow_opts != nil}, filter=#{inspect(effective_filter)}, " <>
"send_packfile=#{send_packfile?}"
)
span_meta = %{
repo_id: state.repo.id,
repo_id: repo.id,
wants: length(wants),
haves: length(haves),
mode: fetch_mode(shallow_opts, effective_filter, wait_for_done?)
mode: fetch_mode(shallow_opts, filter_spec, wait_for_done?)
}
:telemetry.span([:ex_git_objectstore, :protocol, :fetch], span_meta, fn ->
@@ -542,16 +575,25 @@
# recompute it inside do_handle_fetch_stream/8 to keep arity ≤ 8 (credo).
{next_phase, stats} =
do_handle_fetch_stream(
state.repo,
repo,
wants,
haves,
done?,
wait_for_done?,
shallow_opts,
filter_spec,
effective_filter,
write_fn
)
new_state = %{
state
| phase: next_phase,
wants: wants,
haves: haves,
filter_spec: effective_filter,
shallow_opts: shallow_opts
{{next_phase, stats}, Map.merge(span_meta, stats)}
}
{{new_state, stats}, Map.merge(span_meta, stats)}
end)
end
@@ -779,6 +821,8 @@
{:ok, %Tree{} = t} -> {type, Tree.encode_content(t), sha}
{:ok, %Blob{content: content}} -> {type, content, sha}
{:ok, %Tag{} = tag} -> {type, Object.encode_content_only(tag), sha}
{:error, reason} -> raise "failed to read object #{sha}: #{inspect(reason)}"
other -> raise "unexpected object #{sha}: #{inspect(other)}"
end
end)
end
@@ -812,8 +856,10 @@
defp build_generated_response(repo, wants, haves, ack_section, shallow_opts, filter_spec) do
case collect_objects_maybe_shallow(repo, wants, haves, shallow_opts, filter_spec) do
{:ok, %{objects: objects} = walk} ->
Logger.info("UploadPackV2: collected #{length(objects)} objects, generating pack")
{type_shas, count} = drop_content(objects)
Logger.info("UploadPackV2: collected #{count} objects, generating pack")
objects_with_content = Enum.to_list(stream_object_contents(repo, type_shas))
{pack_data, _pack_sha} = Writer.generate(objects_with_content)
{pack_data, _pack_sha} = Writer.generate(objects)
Logger.info("UploadPackV2: pack generated, #{byte_size(pack_data)} bytes")
shallow_info = build_shallow_info(walk)
@@ -902,7 +948,13 @@
defp extract_shas(args, prefix) do
args
|> Enum.filter(&String.starts_with?(&1, prefix))
|> Enum.map(fn line ->
line
|> String.trim_leading(prefix)
|> String.trim()
|> String.split(~r/\s+/, parts: 2)
|> Enum.map(&(&1 |> String.trim_leading(prefix) |> String.trim()))
|> List.first()
end)
end
# -- Object Collection (shared logic with v1 UploadPack) --
@@ -944,8 +996,8 @@
{:ok, %Tree{} = tree} ->
collect_tree_entry_objects(repo, tree, sha, visited, 0, opts)
{:ok, %Blob{content: content}} ->
if opts[:skip_blobs], do: {[], visited}, else: {[{:blob, content, sha}], visited}
{:ok, %Blob{}} ->
if opts[:skip_blobs], do: {[], visited}, else: {[{:blob, nil, sha}], visited}
{:ok, %Tag{} = tag} ->
tag_data = Object.encode_content_only(tag)
@@ -1022,28 +1074,23 @@
{acc, vis}
end
defp collect_single_tree_entry(repo, %{sha: sha}, acc, vis, _depth, opts) do
defp collect_single_tree_entry(_repo, %{sha: sha}, acc, vis, _depth, opts) do
cond do
MapSet.member?(vis, sha) ->
{acc, vis}
opts[:skip_blobs] ->
# Don't include blob in pack. Mark visited so we don't re-check
# on every tree that references this blob.
# Don't read blob content from storage at all. Mark visited so
# we don't re-check on every tree that references this blob.
{acc, MapSet.put(vis, sha)}
true ->
# Record blob without reading payload from storage.
# Content is read lazily during packfile streaming.
{[{:blob, nil, sha} | acc], MapSet.put(vis, sha)}
read_blob_entry(repo, sha, acc, MapSet.put(vis, sha))
end
end
defp read_blob_entry(repo, sha, acc, vis) do
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: content}} -> {[{:blob, content, sha} | acc], vis}
_ -> {acc, vis}
end
end
# -- Shallow Fetch --
# Parse any shallow-related arguments out of the fetch request.
@@ -1168,9 +1215,11 @@
do: compute_tree_metadata(repo, objects),
else: %{depths: %{}, paths: %{}}
meta = Map.put(meta, :spec, spec)
kept =
Enum.filter(objects, fn entry ->
Filter.include?(spec, filter_ctx_for(entry, meta), repo)
Filter.include?(spec, filter_ctx_for(entry, meta, repo), repo)
end)
:telemetry.execute(
@@ -1202,17 +1251,39 @@
do: Enum.any?(specs, &filter_needs_tree_metadata?/1)
defp filter_needs_tree_metadata?(_), do: false
defp filter_needs_blob_size?({:blob_limit, _}), do: true
defp filter_needs_blob_size?({:combine, specs}),
do: Enum.any?(specs, &filter_needs_blob_size?/1)
defp filter_ctx_for({:blob, content, sha}, meta) do
defp filter_needs_blob_size?(_), do: false
defp filter_ctx_for({:blob, content, sha}, meta, repo) do
size =
cond do
content != nil ->
byte_size(content)
filter_needs_blob_size?(meta.spec) ->
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: blob_content}} -> byte_size(blob_content)
_ -> 0
end
true ->
0
end
%{
type: :blob,
size: byte_size(content),
size: size,
tree_depth: Map.get(meta.depths, sha, :not_tree),
path: Map.get(meta.paths, sha)
}
end
defp filter_ctx_for({:tree, _data, sha}, meta, _repo) do
defp filter_ctx_for({:tree, _data, sha}, meta) do
%{
type: :tree,
size: 0,
@@ -1221,10 +1292,10 @@
}
end
defp filter_ctx_for({:commit, _data, _sha}, _meta, _repo) do
defp filter_ctx_for({:commit, _data, _sha}, _meta) do
%{type: :commit, size: 0, tree_depth: :not_tree, path: nil}
end
defp filter_ctx_for({:tag, _data, _sha}, _meta) do
defp filter_ctx_for({:tag, _data, _sha}, _meta, _repo) do
%{type: :tag, size: 0, tree_depth: :not_tree, path: nil}
end
lib/ex_git_objectstore/ref.ex +12 −5
@@ -122,11 +122,18 @@
end
defp resolve_ref(repo, name, depth) do
candidates = [
"refs/heads/#{name}",
"refs/tags/#{name}",
name
]
# An already-qualified ref (e.g. "refs/heads/main", as returned by a
# symbolic HEAD) is looked up directly. Only short names get the
# heads/tags candidate expansion. This matches git's resolution order and,
# crucially, avoids two doomed lookups (refs/heads/refs/heads/main,
# refs/tags/refs/heads/main) per resolution — each a full round-trip, which
# on the S3/RGW backend is ~40 ms of pure waste on every ref-based op.
candidates =
if String.starts_with?(name, "refs/") do
[name]
else
["refs/heads/#{name}", "refs/tags/#{name}", name]
end
Enum.reduce_while(candidates, {:error, :ref_not_found}, fn ref_path, _acc ->
case get(repo, ref_path) do
mix.exs +26 −4
@@ -35,6 +35,14 @@
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
plt_add_apps: [:mix]
],
aliases: aliases(),
cli: cli(),
test_coverage: [summary: [threshold: 50]]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
@@ -91,10 +99,7 @@
defp deps do
[
# Capped below 2.7: ex_aws 2.7 requires hackney ~> 4.0, whose `h2`
# modules collide with `chatterbox` (opentelemetry_exporter) in
# consumers like Anvil. ex_aws 2.6 keeps hackney optional at 1.x.
{:ex_aws, ">= 0.0.0"},
{:ex_aws, "~> 2.6.0"},
{:ex_aws_s3, "~> 2.5"},
{:sweet_xml, "~> 0.7"},
# ex_aws's default HTTP client is Req; using it lets this library drop
@@ -109,5 +114,22 @@
{:bandit, "~> 1.11", only: :test},
{:stream_data, "~> 1.3", only: :test}
]
end
defp aliases do
[
precommit: [
"format --check-formatted",
"compile --warnings-as-errors",
&audit/1,
"test"
]
]
end
defp audit(_) do
Mix.Local.append_archives()
Hex.start()
Mix.Task.run("hex.audit")
end
end
mix.lock +11 −11
@@ -1,29 +1,29 @@
%{
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
"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.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
"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.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [: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", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
"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.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
"plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
"req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"},
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
test/ex_git_objectstore/integration/smart_http_test.exs +139 −0
@@ -83,6 +83,85 @@
stop.()
end
end
@tag :tmp_dir
test "clone using standard git defaults (no protocol.version override)", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("http-clone-default", 2)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
dest = Path.join(tmp_dir, "clone-default")
{out, code} = GitDaemon.git_at(nil, ["clone", "http://127.0.0.1:#{port}/repo", dest])
assert code == 0, "default http clone failed:\n#{out}"
assert File.exists?(Path.join(dest, "f.txt"))
after
stop.()
end
end
@tag :tmp_dir
test "partial clone --filter=blob:none over HTTP with on-demand checkout", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("http-clone-blobless", 2)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
dest = Path.join(tmp_dir, "clone-blobless")
{out, code} =
GitDaemon.git_at(nil, [
"clone",
"--filter=blob:none",
"http://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "http partial clone failed:\n#{out}"
assert File.read!(Path.join(dest, "f.txt")) == "v2\n"
after
stop.()
end
end
@tag :tmp_dir
test "clone over HTTP of repo containing gitlink mode 160000", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("http-clone-gitlink")
ExGitObjectstore.init(repo)
blob = Blob.from_content("submodule test\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree =
Tree.new([
%{mode: "100644", name: "f.txt", sha: blob_sha},
%{mode: "160000", name: "submodule", sha: "fedcba9876543210fedcba9876543210fedcba98"}
])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: "submodule commit\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
dest = Path.join(tmp_dir, "clone-http-gitlink")
{out, code} = GitDaemon.git_at(nil, ["clone", "http://127.0.0.1:#{port}/repo", dest])
assert code == 0, "http clone with gitlink failed:\n#{out}"
assert File.read!(Path.join(dest, "f.txt")) == "submodule test\n"
after
stop.()
end
end
end
describe "fetch over HTTP smart protocol" do
@@ -214,6 +293,66 @@
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, ^client_sha} = Ref.get(repo, "refs/heads/main")
after
stop.()
end
end
@tag :tmp_dir
test "push atomic with annotated tags and branch deletion", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("http-push-atomic-tags")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_http_smart(repo)
remote_url = "http://127.0.0.1:#{port}/repo"
try do
client = GitDaemon.init_client_dir(tmp_dir)
File.write!(Path.join(client, "app.txt"), "hello world\n")
GitDaemon.git!(client, ["add", "app.txt"])
GitDaemon.git!(client, ["commit", "-m", "initial commit"])
# Create annotated and lightweight tags
GitDaemon.git!(client, ["tag", "-a", "v1.0.0", "-m", "release 1.0.0"])
GitDaemon.git!(client, ["tag", "v1.0.0-lw"])
# Push main and tags with --atomic
{out, code} =
GitDaemon.git_at(client, [
"push",
"--atomic",
"--tags",
remote_url,
"main:refs/heads/main"
])
assert code == 0, "atomic push failed:\n#{out}"
# Create a feature branch and push it
GitDaemon.git!(client, ["checkout", "-b", "feature-x"])
File.write!(Path.join(client, "feature.txt"), "feature data\n")
GitDaemon.git!(client, ["add", "feature.txt"])
GitDaemon.git!(client, ["commit", "-m", "feature commit"])
{out2, code2} =
GitDaemon.git_at(client, ["push", remote_url, "feature-x:refs/heads/feature-x"])
assert code2 == 0, "push feature branch failed:\n#{out2}"
assert {:ok, _} = Ref.get(repo, "refs/heads/feature-x")
# Delete the feature branch via push
{out3, code3} = GitDaemon.git_at(client, ["push", remote_url, "--delete", "feature-x"])
assert code3 == 0, "delete branch push failed:\n#{out3}"
assert {:error, :not_found} = Ref.get(repo, "refs/heads/feature-x")
# Verify a fresh clone sees the tags and main branch correctly
clone_dest = Path.join(tmp_dir, "fresh-clone")
{out4, code4} = GitDaemon.git_at(nil, ["clone", remote_url, clone_dest])
assert code4 == 0, "clone failed:\n#{out4}"
{tags_out, _} = GitDaemon.git_at(clone_dest, ["tag", "-l"])
tag_list = String.split(String.trim(tags_out), "\n")
assert "v1.0.0" in tag_list
assert "v1.0.0-lw" in tag_list
after
stop.()
end
test/ex_git_objectstore/integration/upload_pack_v2_git_client_test.exs +128 −0
@@ -115,6 +115,83 @@
stop.()
end
end
@tag :tmp_dir
test "partial clone --filter=blob:none with on-demand checkout", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("clone-partial-checkout")
ExGitObjectstore.init(repo)
sha = create_commit(repo, "blob payload to be fetched lazily\n", "blobless commit\n")
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
{port, stop} = start_git_daemon(repo)
try do
dest = Path.join(tmp_dir, "blobless-clone")
{out, code} =
System.cmd(
"git",
[
"-c",
"protocol.version=2",
"clone",
"--filter=blob:none",
"git://127.0.0.1:#{port}/repo",
dest
],
stderr_to_stdout: true,
env: [{"GIT_TERMINAL_PROMPT", "0"}]
)
assert code == 0, "git clone --filter=blob:none failed (exit #{code}):\n#{out}"
assert File.read!(Path.join(dest, "file.txt")) == "blob payload to be fetched lazily\n"
after
stop.()
end
end
@tag :tmp_dir
test "clone of repo with gitlink submodule mode 160000", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("clone-gitlink")
ExGitObjectstore.init(repo)
# Create commit with file and a submodule (mode 160000)
blob = Blob.from_content("main file\n")
{:ok, blob_sha} = Object.write(repo, blob)
# Submodule commit SHA pointing to foreign commit
submodule_sha = "0123456789abcdef0123456789abcdef01234567"
tree =
Tree.new([
%{mode: "100644", name: "file.txt", sha: blob_sha},
%{mode: "160000", name: "submodule", sha: submodule_sha}
])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <t@t.com> 1000000000 +0000",
committer: "Test <t@t.com> 1000000000 +0000",
message: "Commit with submodule\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{port, stop} = start_git_daemon(repo)
try do
dest = Path.join(tmp_dir, "gitlink-clone")
{out, code} = git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "git clone with submodule failed (exit #{code}):\n#{out}"
assert File.read!(Path.join(dest, "file.txt")) == "main file\n"
after
stop.()
end
end
end
describe "git fetch against UploadPackV2 over git://" do
@@ -216,6 +293,57 @@
)
assert code2 == 0, "git fetch no-op failed (exit #{code2}):\n#{out2}"
after
stop.()
end
end
@tag :tmp_dir
test "fetch with deep history forcing multi-round negotiation", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("fetch-multiround")
ExGitObjectstore.init(repo)
base_sha = create_commit(repo, "base\n", "base commit\n")
:ok = Ref.put(repo, "refs/heads/main", base_sha, nil)
_server_tip =
Enum.reduce(1..10, base_sha, fn i, parent ->
sha = create_commit(repo, "server #{i}\n", "server commit #{i}\n", [parent])
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
sha
end)
client_dir = Path.join(tmp_dir, "client-deep")
File.mkdir_p!(client_dir)
git!(client_dir, ["init", "--initial-branch=main"])
git!(client_dir, ["config", "user.email", "t@t.com"])
git!(client_dir, ["config", "user.name", "t"])
for i <- 1..40 do
File.write!(Path.join(client_dir, "local_#{i}.txt"), "data #{i}\n")
git!(client_dir, ["add", "local_#{i}.txt"])
git!(client_dir, ["commit", "-m", "local #{i}"])
end
{port, stop} = start_git_daemon(repo)
try do
{out, code} =
System.cmd(
"git",
[
"-c",
"protocol.version=2",
"fetch",
"git://127.0.0.1:#{port}/repo",
"main"
],
cd: client_dir,
stderr_to_stdout: true,
env: [{"GIT_TERMINAL_PROMPT", "0"}]
)
assert code == 0, "multi-round git fetch failed (exit #{code}):\n#{out}"
after
stop.()
end
test/ex_git_objectstore/merge/diff3_test.exs +9 −0
@@ -263,5 +263,14 @@
test "binary content is reported as :binary" do
assert :binary = Diff3.merge_markers("a\n", "\x00", "b\n")
end
test "repeated-line 3-way merge conflict detection" do
base = "line1\nline1\nline1\n"
ours = "line1\n"
theirs = "line1\nline1\nline2\n"
assert {:conflict, conflict_text} = Diff3.merge_markers(base, ours, theirs)
assert String.contains?(conflict_text, "<<<<<<<")
end
end
end
test/ex_git_objectstore/protocol/upload_pack_v2_test.exs +80 −0
@@ -426,6 +426,48 @@
assert String.contains?(response, "packfile"),
"Client sent `done` — server must send a packfile section."
end
test "multi-round negotiation: round 2 omits want list and sends have + done" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{c1_sha, _, _} = create_commit(repo, "v1\n", "first\n")
:ok = Ref.put(repo, "refs/heads/main", c1_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
fake_have_1 = String.duplicate("1", 40)
fake_have_2 = String.duplicate("2", 40)
# Round 1: client sends wants + first batch of haves (no done)
round1_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{c1_sha}") <>
PktLine.encode("have #{fake_have_1}") <>
PktLine.flush()
{response1, state} = UploadPackV2.feed(state, round1_data)
assert state.phase == :command
assert String.contains?(response1, "acknowledgments")
assert String.contains?(response1, "NAK")
# Round 2: client sends second batch of haves + done (wants omitted per v2 spec)
round2_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("have #{fake_have_2}") <>
PktLine.encode("done") <>
PktLine.flush()
{response2, state} = UploadPackV2.feed(state, round2_data)
assert state.phase == :done
assert String.contains?(response2, "packfile")
pack_data = extract_sideband_pack(response2)
assert pack_data != nil
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 3
end
end
describe "full HTTP-style flow (ls-refs then fetch)" do
@@ -1094,5 +1136,43 @@
after
File.rm_rf!(tmp_dir)
end
end
test "handles missing object lookup gracefully during resolver read" do
repo = RepoHelper.memory_repo()
missing_sha = String.duplicate("0", 40)
assert ExGitObjectstore.ObjectResolver.read(repo, missing_sha) == {:error, :not_found}
end
test "pack generation traverses tree and stream packs reachable objects" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{:ok, blob_sha} = Object.write(repo, Blob.from_content("blob data"))
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Dev <dev@test.com> 1000000000 +0000",
committer: "Dev <dev@test.com> 1000000000 +0000",
message: "Init\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
client_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{commit_sha}") <>
PktLine.encode("done") <>
PktLine.flush()
{_advert, state} = UploadPackV2.init(repo)
{streamed, new_state} = collect_stream(state, client_data)
assert is_binary(streamed)
assert UploadPackV2.done?(new_state)
end
end
test/ex_git_objectstore/walk_test.exs +44 −0
@@ -622,5 +622,49 @@
assert {:ok, sizes} = ExGitObjectstore.blob_sizes(repo, [blob_sha, tree_sha])
assert sizes == %{blob_sha => 4}
end
test "merge_base DAG traversal when child commit has older timestamp than parent", %{
repo: repo
} do
tree = Tree.new([])
{:ok, tree_sha} = Object.write(repo, tree)
# 1. Root commit (ts 1000)
{:ok, root_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [],
author: "Dev <dev@test.com> 1000 +0000",
message: "Root\n"
)
# 2. Common ancestor commit (ts 2000)
{:ok, common_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [root_sha],
author: "Dev <dev@test.com> 2000 +0000",
message: "Common\n"
)
# 3. Branch A tip (ts 3000)
{:ok, branch_a_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [common_sha],
author: "Dev <dev@test.com> 3000 +0000",
message: "Branch A\n"
)
# 4. Branch B tip (clock skew: ts 1500, older than parent's 2000)
{:ok, branch_b_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [common_sha],
author: "Dev <dev@test.com> 1500 +0000",
message: "Branch B\n"
)
assert {:ok, merge_base_sha} =
ExGitObjectstore.Walk.merge_base(repo, branch_a_sha, branch_b_sha)
assert merge_base_sha == common_sha
end
end
end