@@ -1,0 +1,314 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Protocol.UploadPackV2Test do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Pack.Reader
alias ExGitObjectstore.Protocol.{PktLine, UploadPackV2}
alias ExGitObjectstore.Test.RepoHelper
defp create_commit(repo, content, message, parents \\ []) do
blob = Blob.from_content(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)
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "Test <t@t.com> 1000000000 +0000",
committer: "Test <t@t.com> 1000000000 +0000",
message: message
}
{:ok, commit_sha} = Object.write(repo, commit)
{commit_sha, blob_sha, tree_sha}
end
describe "init" do
test "advertises v2 capabilities" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{advert, state} = UploadPackV2.init(repo)
assert state.phase == :command
assert state.buffer == <<>>
{:ok, packets, _rest} = PktLine.decode(advert)
data_lines = for {:data, d} <- packets, do: d
assert "version 2" in data_lines
assert "ls-refs" in data_lines
assert :flush in packets
end
end
describe "ls-refs" do
test "returns refs for a repo with commits" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello", "init")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
# Build a ls-refs command
ls_refs_cmd =
IO.iodata_to_binary([
PktLine.encode("command=ls-refs"),
PktLine.delim(),
PktLine.encode("ref-prefix refs/heads/"),
PktLine.flush()
])
{response, new_state} = UploadPackV2.feed(state, ls_refs_cmd)
# Should still be in :command phase (ls-refs doesn't end the session)
assert new_state.phase == :command
assert new_state.buffer == <<>>
assert byte_size(response) > 0
{:ok, packets, _} = PktLine.decode(response)
data_lines = for {:data, d} <- packets, do: d
assert Enum.any?(data_lines, &String.contains?(&1, commit_sha))
end
end
describe "fetch" do
test "returns packfile for a simple clone" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello", "init")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
# Build a fetch command
fetch_cmd =
IO.iodata_to_binary([
PktLine.encode("command=fetch"),
PktLine.delim(),
PktLine.encode("want #{commit_sha}"),
PktLine.encode("done"),
PktLine.flush()
])
{response, new_state} = UploadPackV2.feed(state, fetch_cmd)
assert new_state.phase == :done
assert byte_size(response) > 0
# Response should contain "packfile" section
{:ok, packets, _} = PktLine.decode(response)
data_lines = for {:data, d} <- packets, do: d
assert "packfile" in data_lines
end
end
describe "buffering split data" do
test "handles ls-refs command split across two feed calls" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello", "init")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
# Build a complete ls-refs command
full_cmd =
IO.iodata_to_binary([
PktLine.encode("command=ls-refs"),
PktLine.delim(),
PktLine.encode("ref-prefix refs/heads/"),
PktLine.flush()
])
# Split at an arbitrary point mid-command
split_point = div(byte_size(full_cmd), 2)
<<part1::binary-size(split_point), part2::binary>> = full_cmd
# First feed: should buffer, return empty response
{response1, state1} = UploadPackV2.feed(state, part1)
assert response1 == <<>>
assert state1.phase == :command
assert state1.buffer == part1
# Second feed: should complete the command
{response2, state2} = UploadPackV2.feed(state1, part2)
assert state2.phase == :command
assert state2.buffer == <<>>
assert byte_size(response2) > 0
{:ok, packets, _} = PktLine.decode(response2)
data_lines = for {:data, d} <- packets, do: d
assert Enum.any?(data_lines, &String.contains?(&1, commit_sha))
end
test "handles fetch command split across three feed calls" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "content", "commit")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
# Build a complete fetch command
full_cmd =
IO.iodata_to_binary([
PktLine.encode("command=fetch"),
PktLine.delim(),
PktLine.encode("want #{commit_sha}"),
PktLine.encode("done"),
PktLine.flush()
])
# Split into three parts
size = byte_size(full_cmd)
s1 = div(size, 3)
s2 = div(size * 2, 3)
<<p1::binary-size(s1), rest::binary>> = full_cmd
<<p2::binary-size(s2 - s1), p3::binary>> = rest
# Feed part 1
{r1, st1} = UploadPackV2.feed(state, p1)
assert r1 == <<>>
assert st1.phase == :command
# Feed part 2
{r2, st2} = UploadPackV2.feed(st1, p2)
assert r2 == <<>>
assert st2.phase == :command
# Feed part 3 — should complete
{r3, st3} = UploadPackV2.feed(st2, p3)
assert st3.phase == :done
assert byte_size(r3) > 0
{:ok, packets, _} = PktLine.decode(r3)
data_lines = for {:data, d} <- packets, do: d
assert "packfile" in data_lines
end
test "handles split at exact pkt-line boundary" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "test", "init")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
# Split exactly between the command line and the delim
command_line = PktLine.encode("command=ls-refs")
rest_of_cmd = IO.iodata_to_binary([PktLine.delim(), PktLine.flush()])
{r1, st1} = UploadPackV2.feed(state, command_line)
assert r1 == <<>>
assert st1.phase == :command
{r2, st2} = UploadPackV2.feed(st1, rest_of_cmd)
assert st2.phase == :command
assert byte_size(r2) > 0
end
end
describe "full v2 conversation" do
test "ls-refs followed by fetch" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello world", "initial")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPackV2.init(repo)
# Step 1: ls-refs
ls_refs =
IO.iodata_to_binary([
PktLine.encode("command=ls-refs"),
PktLine.delim(),
PktLine.encode("ref-prefix refs/heads/"),
PktLine.flush()
])
{ls_response, state} = UploadPackV2.feed(state, ls_refs)
assert state.phase == :command
{:ok, ls_packets, _} = PktLine.decode(ls_response)
data_lines = for {:data, d} <- ls_packets, do: d
assert Enum.any?(data_lines, &String.contains?(&1, commit_sha))
# Step 2: fetch
fetch =
IO.iodata_to_binary([
PktLine.encode("command=fetch"),
PktLine.delim(),
PktLine.encode("want #{commit_sha}"),
PktLine.encode("done"),
PktLine.flush()
])
{fetch_response, state} = UploadPackV2.feed(state, fetch)
assert state.phase == :done
assert byte_size(fetch_response) > 0
# Verify packfile in response
{:ok, fetch_packets, _} = PktLine.decode(fetch_response)
data_lines = for {:data, d} <- fetch_packets, do: d
assert "packfile" in data_lines
# Extract and verify pack data
pack_data = extract_pack_data(fetch_response)
{:ok, objects} = Reader.parse(pack_data)
types = Enum.map(objects, fn obj -> obj.type end)
assert :commit in types
assert :tree in types
assert :blob in types
end
end
# Extract raw pack data from sideband-encoded response
defp extract_pack_data(response) do
{:ok, packets, _} = PktLine.decode(response)
# Find the packfile marker, then collect sideband-1 data
{_, after_packfile} =
Enum.split_while(packets, fn
{:data, "packfile"} -> false
_ -> true
end)
after_packfile
|> tl()
|> Enum.flat_map(fn
{:data, data} ->
case PktLine.decode_sideband(data) do
{:pack, pack} -> [pack]
_ -> []
end
_ ->
[]
end)
|> IO.iodata_to_binary()
end
end