@@ -1,0 +1,317 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Integration.UploadPackV2GitClientTest do
@moduledoc """
End-to-end validation of UploadPackV2 against the real `git` CLI.
Each test spins up a minimal TCP server that speaks the `git://`
protocol (service line + protocol-v2 stream) and serves an
in-memory repository through `UploadPackV2`. A subprocess invocation
of `git clone` / `git fetch` is then run against `127.0.0.1:<port>`
and its exit code + stderr are asserted.
This catches protocol framing bugs that pure state-machine tests
miss, because a real client strictly enforces the v2 response
grammar (e.g. `acknowledgments` section must end with `ready` if
a `packfile` section follows).
"""
use ExUnit.Case, async: false
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Protocol.{PktLine, UploadPackV2}
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.RepoHelper
@moduletag :integration
@moduletag timeout: :timer.minutes(1)
# --- fixtures ---
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
end
# --- tests ---
describe "git clone against UploadPackV2 over git://" do
@tag :tmp_dir
test "clone of a single-commit repo succeeds", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("clone-single")
ExGitObjectstore.init(repo)
sha = create_commit(repo, "hello\n", "initial\n")
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
{port, stop} = start_git_daemon(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} = git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "git clone failed (exit #{code}):\n#{out}"
assert File.exists?(Path.join(dest, "file.txt"))
after
stop.()
end
end
@tag :tmp_dir
test "clone of a multi-commit chain succeeds", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("clone-chain")
ExGitObjectstore.init(repo)
{_commits, _} =
Enum.reduce(1..5, {[], nil}, fn i, {acc, parent} ->
parents = if parent, do: [parent], else: []
sha = create_commit(repo, "v#{i}\n", "commit #{i}\n", parents)
if i == 1 do
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
else
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
end
{[sha | acc], sha}
end)
{port, stop} = start_git_daemon(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} = git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "git clone failed (exit #{code}):\n#{out}"
# Should have 5 commits on main
{log, _} = System.cmd("git", ["-C", dest, "log", "--oneline"], stderr_to_stdout: true)
assert length(String.split(String.trim(log), "\n")) == 5
after
stop.()
end
end
end
describe "git fetch against UploadPackV2 over git://" do
# Regression: hephaestus failed with
# fatal: expected no other sections to be sent after no 'ready'
# when the local clone had commits the server didn't know about, so the
# client sent haves that didn't match. The server emitted NAK + packfile
# which violates protocol v2.
@tag :tmp_dir
test "fetch works when client has unrelated history (hephaestus regression)",
%{tmp_dir: tmp_dir} do
# Server repo — single commit
repo = RepoHelper.memory_repo("fetch-divergent")
ExGitObjectstore.init(repo)
server_sha = create_commit(repo, "server\n", "server commit\n")
:ok = Ref.put(repo, "refs/heads/main", server_sha, nil)
# Client repo with its own unrelated history — so fetching will send
# haves that the server does not know about.
client_dir = Path.join(tmp_dir, "client")
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"])
File.write!(Path.join(client_dir, "local.txt"), "local\n")
git!(client_dir, ["add", "local.txt"])
git!(client_dir, ["commit", "-m", "unrelated local commit"])
{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"}]
)
refute String.contains?(out, "expected no other sections to be sent after no 'ready'"),
"hephaestus regression — server response violates protocol v2:\n#{out}"
assert code == 0, "git fetch failed (exit #{code}):\n#{out}"
after
stop.()
end
end
@tag :tmp_dir
test "fetch after clone is a no-op (matching haves)", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("fetch-noop")
ExGitObjectstore.init(repo)
sha = create_commit(repo, "a\n", "a\n")
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
{port, stop} = start_git_daemon(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} = git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "git clone failed (exit #{code}):\n#{out}"
# Fetch again — client has the tip, should negotiate and receive
# an empty (or nearly empty) pack.
{out2, code2} =
System.cmd(
"git",
["-c", "protocol.version=2", "fetch", "origin", "main"],
cd: dest,
stderr_to_stdout: true,
env: [{"GIT_TERMINAL_PROMPT", "0"}]
)
assert code2 == 0, "git fetch no-op failed (exit #{code2}):\n#{out2}"
after
stop.()
end
end
end
# --- helpers ---
defp git!(dir, args) do
{out, code} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
if code != 0, do: raise("git #{Enum.join(args, " ")} failed: #{out}")
String.trim(out)
end
defp git_clone(url, dest) do
System.cmd(
"git",
["-c", "protocol.version=2", "clone", url, dest],
stderr_to_stdout: true,
env: [{"GIT_TERMINAL_PROMPT", "0"}]
)
end
# Spin up a minimal git:// daemon serving one repo over UploadPackV2.
# Returns `{port, stop_fn}`.
defp start_git_daemon(repo) do
{:ok, listen} =
:gen_tcp.listen(0, [
:binary,
active: false,
reuseaddr: true,
packet: :raw,
ip: {127, 0, 0, 1}
])
{:ok, port} = :inet.port(listen)
# Unlinked: stopping the daemon (closing the listen socket) must not take
# the test process with it.
spawn(fn -> accept_loop(listen, repo) end)
stop = fn -> :gen_tcp.close(listen) end
{port, stop}
end
defp accept_loop(listen, repo) do
case :gen_tcp.accept(listen, 10_000) do
{:ok, client} ->
spawn(fn -> serve_client(client, repo) end)
accept_loop(listen, repo)
{:error, :closed} ->
:ok
{:error, _} = err ->
err
end
end
# Each connection = one git:// session.
# Flow:
# 1. Client sends a single pkt-line:
# `git-upload-pack <path>\0host=<h>\0[\0version=2\0]`
# 2. If version=2, we send the v2 capability advertisement and then
# forward subsequent bytes into UploadPackV2 until the state machine
# reports done.
defp serve_client(client, repo) do
with {:ok, service_line} <- read_one_pkt(client),
true <- v2_requested?(service_line) do
{advert, state} = UploadPackV2.init(repo)
:ok = :gen_tcp.send(client, advert)
drive(client, state)
else
_ -> :ok
end
after
:gen_tcp.close(client)
end
defp drive(client, state) do
case :gen_tcp.recv(client, 0, 15_000) do
{:ok, data} ->
{response, new_state} = UploadPackV2.feed(state, data)
if byte_size(response) > 0, do: :ok = :gen_tcp.send(client, response)
if UploadPackV2.done?(new_state) do
:ok
else
drive(client, new_state)
end
{:error, _} ->
:ok
end
end
# Read exactly one pkt-line from the socket. Returns `{:ok, payload}` or
# `{:error, reason}`.
defp read_one_pkt(client) do
with {:ok, <<hex::binary-size(4)>>} <- :gen_tcp.recv(client, 4, 5_000),
{len, ""} <- Integer.parse(hex, 16) do
read_pkt_payload(client, len)
else
:error -> {:error, :bad_pkt_len}
{_, _} -> {:error, :bad_pkt_len}
err -> err
end
end
defp read_pkt_payload(_client, 0), do: {:ok, ""}
defp read_pkt_payload(_client, len) when len < 4, do: {:error, :bad_pkt_len}
defp read_pkt_payload(client, len) do
:gen_tcp.recv(client, len - 4, 5_000)
end
defp v2_requested?(line) do
# git://-style service line contains NUL-separated args; v2 clients
# include `version=2` as one of them.
String.contains?(line, "version=2")
end
# quiet unused warning on future tweaks
_ = PktLine
end