ref:7991e7274ac8959e0693f8b3333c14b4cffa8270

test: realistic-client protocol coverage (6 new integration suites)

Adds 7 new files under test/ that drive real `git` / real HTTP clients through UploadPackV2 and ReceivePack state machines, covering the protocol paths that pure state-machine tests miss. Shared test harness: test/support/git_daemon.ex - `start_upload_pack/1` — git:// TCP daemon serving UploadPackV2 - `start_receive_pack/2` — git:// TCP daemon serving ReceivePack - `start_http_smart/2` — minimal HTTP/1.1 mini-server that routes GET /info/refs + POST /git-(upload|receive)-pack through the state machines (no Plug/Cowboy dep) - `git_at` / `git!` / `init_client_dir` / `seed_client_clone` helpers - Per-connection `repo_fun` lets a single daemon swap the underlying repo between connections (for stale-haves scenarios) Test suites: integration/git_daemon_smoke_test.exs — harness smoke integration/upload_pack_v2_negotiation_test.exs — stale haves, partial overlap, deep divergence, negotiate-only, concurrent push/fetch integration/upload_pack_v2_capabilities_test.exs — symrefs, peel, unborn, shallow, deepen, blob:none, tree:0, wait-for-done, capability-advertisement canary integration/upload_pack_v2_dataplane_test.exs — >64KB sideband, >1MB pack (slow), 250 refs, ref-prefix filter, client-disconnect-mid-pack, TCP-chunk invariance integration/receive_pack_git_client_test.exs — new branch, fast-forward, non-ff +/- force, delete ref, atomic, update-hook rejection, thin-pack REF_DELTA, concurrent push integration/smart_http_test.exs — clone / fetch / push over HTTP smart protocol, two-daemon stale-haves, direct POST (no git client) Results: 753 tests, 0 failures, 12 skipped. Skipped tests are each tagged with a `@tag skip: "..."` message + (where applicable) an issue link. The master tracking issue is #30. Notable state-machine bug caught by the TCP-chunking test is tracked as #29. Intentionally no production code changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHA: 7991e7274ac8959e0693f8b3333c14b4cffa8270
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-19 00:03
Parents: b0352c4
7 files changed +2533 -0
Type
test/ex_git_objectstore/integration/git_daemon_smoke_test.exs +138 −0
@@ -1,0 +1,138 @@
# 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.GitDaemonSmokeTest do
@moduledoc """
Minimal smoke tests for the shared `GitDaemon` helper. These verify
the helper plumbing works; detailed protocol behaviour is exercised
in the other integration test files.
"""
use ExUnit.Case, async: false
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.{GitDaemon, RepoHelper}
@moduletag :integration
@moduletag timeout: :timer.seconds(30)
@tag :tmp_dir
test "upload-pack daemon serves a simple clone", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("smoke-clone")
ExGitObjectstore.init(repo)
sha = make_commit(repo, "hello\n", "initial\n")
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} = GitDaemon.git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "clone failed:\n#{out}"
assert File.read!(Path.join(dest, "file.txt")) == "hello\n"
after
stop.()
end
end
@tag :tmp_dir
test "receive-pack daemon serves a simple push", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("smoke-push")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_receive_pack(repo)
try do
client_dir = GitDaemon.init_client_dir(tmp_dir, "sender")
File.write!(Path.join(client_dir, "pushed.txt"), "pushed\n")
GitDaemon.git!(client_dir, ["add", "pushed.txt"])
GitDaemon.git!(client_dir, ["commit", "-m", "first"])
{out, code} =
GitDaemon.git_at(client_dir, [
"-c",
"protocol.version=2",
"push",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
assert code == 0, "push failed:\n#{out}"
# Verify the ref landed on the server.
assert {:ok, _sha} = Ref.get(repo, "refs/heads/main")
after
stop.()
end
end
@tag :tmp_dir
test "repo_fun is called per-connection (supports stale-haves scenarios)",
%{tmp_dir: tmp_dir} do
repo_a = RepoHelper.memory_repo("smoke-a")
ExGitObjectstore.init(repo_a)
sha_a = make_commit(repo_a, "a\n", "a\n")
:ok = Ref.put(repo_a, "refs/heads/main", sha_a, nil)
repo_b = RepoHelper.memory_repo("smoke-b")
ExGitObjectstore.init(repo_b)
sha_b = make_commit(repo_b, "b\n", "b\n")
:ok = Ref.put(repo_b, "refs/heads/main", sha_b, nil)
# The repo_fun flips between the two repos on each call. The first
# clone will see repo_a, the second connection will see repo_b.
counter = :counters.new(1, [:atomics])
repo_fun = fn ->
n = :counters.get(counter, 1)
:counters.add(counter, 1, 1)
if rem(n, 2) == 0, do: repo_a, else: repo_b
end
{port, stop} = GitDaemon.start_upload_pack(repo_fun)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} = GitDaemon.git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "clone failed:\n#{out}"
# First connection → repo_a.
assert File.read!(Path.join(dest, "file.txt")) == "a\n"
after
stop.()
end
end
# --- fixtures ---
defp make_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, sha} = Object.write(repo, commit)
sha
end
end
test/ex_git_objectstore/integration/receive_pack_git_client_test.exs +425 −0
@@ -1,0 +1,425 @@
# 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.ReceivePackGitClientTest do
@moduledoc """
End-to-end validation of ReceivePack against the real `git push`.
The existing `protocol_interop_test.exs` covers ReceivePack's state
machine against `git pack-objects` output, but no test actually runs
`git push` through a TCP daemon. This file fills that gap — each
test boots a `ReceivePack` daemon on a random local port and drives
it with a real `git push` subprocess.
"""
use ExUnit.Case, async: false
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.{GitDaemon, RepoHelper}
@moduletag :integration
@moduletag timeout: :timer.minutes(1)
describe "basic push" do
@tag :tmp_dir
test "push new branch (fast-forward from empty)", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("push-new")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_receive_pack(repo)
try do
client = GitDaemon.init_client_dir(tmp_dir)
File.write!(Path.join(client, "a.txt"), "hello\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "first"])
{out, code} =
GitDaemon.git_at(client, [
"push",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
assert code == 0, "push failed:\n#{out}"
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, server_sha} = Ref.get(repo, "refs/heads/main")
assert client_sha == server_sha
after
stop.()
end
end
@tag :tmp_dir
test "fast-forward push of a second commit", %{tmp_dir: tmp_dir} do
{port, stop, repo} = seeded_receive_pack("ff", [{"a.txt", "v1\n"}])
try do
client = Path.join(tmp_dir, "client")
{upload_port, stop_upload} = GitDaemon.start_upload_pack(repo)
try do
GitDaemon.seed_client_clone("git://127.0.0.1:#{upload_port}/repo", client)
after
stop_upload.()
end
File.write!(Path.join(client, "a.txt"), "v2\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "second"])
{out, code} =
GitDaemon.git_at(client, [
"push",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
assert code == 0, "ff push failed:\n#{out}"
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, ^client_sha} = Ref.get(repo, "refs/heads/main")
after
stop.()
end
end
end
describe "non-fast-forward" do
@tag :tmp_dir
test "non-ff push is rejected by default, accepted with --force",
%{tmp_dir: tmp_dir} do
{port, stop, repo} = seeded_receive_pack("nonff", [{"a.txt", "v1\n"}, {"a.txt", "v2\n"}])
try do
{upload_port, stop_upload} = GitDaemon.start_upload_pack(repo)
client = Path.join(tmp_dir, "client")
try do
GitDaemon.seed_client_clone("git://127.0.0.1:#{upload_port}/repo", client)
after
stop_upload.()
end
# Rewrite history on the client so the new tip is not a
# descendant of the current server tip.
GitDaemon.git!(client, ["reset", "--hard", "HEAD~1"])
File.write!(Path.join(client, "a.txt"), "divergent\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "rewritten"])
{out, code} =
GitDaemon.git_at(client, [
"push",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
refute code == 0, "non-ff push should be rejected without --force:\n#{out}"
assert out =~ ~r/reject|non-fast-forward|non-ff/i,
"expected rejection message:\n#{out}"
{out_f, code_f} =
GitDaemon.git_at(client, [
"push",
"--force",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
assert code_f == 0, "forced push failed:\n#{out_f}"
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, ^client_sha} = Ref.get(repo, "refs/heads/main")
after
stop.()
end
end
end
describe "delete ref" do
@tag :tmp_dir
test "push deletes a ref with `:refs/heads/foo`", %{tmp_dir: tmp_dir} do
{port, stop, repo} =
seeded_receive_pack("delete", [{"a.txt", "v1\n"}], extra_refs: ["refs/heads/doomed"])
try do
client = Path.join(tmp_dir, "client")
File.mkdir_p!(client)
# Minimal client that knows nothing — just issues the delete.
{upload_port, stop_upload} = GitDaemon.start_upload_pack(repo)
try do
GitDaemon.seed_client_clone("git://127.0.0.1:#{upload_port}/repo", client)
after
stop_upload.()
end
{out, code} =
GitDaemon.git_at(client, [
"push",
"git://127.0.0.1:#{port}/repo",
":refs/heads/doomed"
])
assert code == 0, "delete push failed:\n#{out}"
assert {:error, _} = Ref.get(repo, "refs/heads/doomed")
after
stop.()
end
end
end
describe "--atomic" do
# `git push --atomic` tells the server: either apply all of these
# ref updates or none of them. ReceivePack must refuse the whole
# batch if any one update would fail (invalid ref name, hook
# rejection, non-ff without --force, etc.).
@tag :tmp_dir
@tag skip: "ReceivePack does not yet implement `atomic` capability; #TBD"
test "atomic push rejects the whole batch when one ref is bad",
%{tmp_dir: tmp_dir} do
{port, stop, repo} = seeded_receive_pack("atomic", [{"a.txt", "v1\n"}])
try do
{upload_port, stop_upload} = GitDaemon.start_upload_pack(repo)
client = Path.join(tmp_dir, "client")
try do
GitDaemon.seed_client_clone("git://127.0.0.1:#{upload_port}/repo", client)
after
stop_upload.()
end
File.write!(Path.join(client, "a.txt"), "v2\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "second"])
server_before = Ref.get(repo, "refs/heads/main")
# Push a good ref and an impossible ref name together. The
# "bad" ref update should take down the whole atomic batch.
{out, code} =
GitDaemon.git_at(client, [
"push",
"--atomic",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main",
"main:refs/not-a-valid-prefix/xyz"
])
refute code == 0, "atomic push with bad ref should fail:\n#{out}"
assert Ref.get(repo, "refs/heads/main") == server_before,
"atomic batch was partially applied"
after
stop.()
end
end
end
describe "hooks" do
# ReceivePack accepts a pre_receive_hook / update_hook that can reject
# individual updates. Real git clients surface the hook's `ng`
# report-status to the user.
@tag :tmp_dir
test "update_hook can reject a push", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("hook-reject")
ExGitObjectstore.init(repo)
hook = fn _ref, _old, _new -> {:error, "hook says no"} end
{port, stop} = GitDaemon.start_receive_pack(repo, update_hook: hook)
try do
client = GitDaemon.init_client_dir(tmp_dir)
File.write!(Path.join(client, "a.txt"), "hi\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "first"])
{out, code} =
GitDaemon.git_at(client, [
"push",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
refute code == 0, "hook-rejected push should fail:\n#{out}"
assert out =~ ~r/hook says no/i or out =~ ~r/rejected/i,
"expected hook/rejection message:\n#{out}"
assert {:error, _} = Ref.get(repo, "refs/heads/main"),
"ref was updated despite hook rejection"
after
stop.()
end
end
end
describe "thin packs" do
@tag :tmp_dir
test "thin-pack push with REF_DELTA against existing server objects",
%{tmp_dir: tmp_dir} do
{port, stop, repo} =
seeded_receive_pack("thin", [
{"f.txt", String.duplicate("base content\n", 50)}
])
try do
{upload_port, stop_upload} = GitDaemon.start_upload_pack(repo)
client = Path.join(tmp_dir, "client")
try do
GitDaemon.seed_client_clone("git://127.0.0.1:#{upload_port}/repo", client)
after
stop_upload.()
end
# Make a new commit with content similar to the base — git will
# likely emit a REF_DELTA against the existing blob when packing.
File.write!(
Path.join(client, "f.txt"),
String.duplicate("base content\n", 50) <> "added line\n"
)
GitDaemon.git!(client, ["add", "f.txt"])
GitDaemon.git!(client, ["commit", "-m", "delta-friendly"])
{out, code} =
GitDaemon.git_at(client, [
"push",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
assert code == 0, "thin-pack push failed:\n#{out}"
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, ^client_sha} = Ref.get(repo, "refs/heads/main")
after
stop.()
end
end
end
describe "concurrency" do
# Two clients push simultaneously. ReceivePack does not yet have a
# server-side lock on ref update; the second writer may either
# succeed (clobbering the first) or fail with a non-ff rejection.
# The test asserts at least ONE succeeds and the final ref state is
# one of the two submitted tips (no corruption).
@tag :tmp_dir
test "concurrent pushes to same ref end in a consistent state",
%{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("concurrent-push")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_receive_pack(repo)
try do
client_a = GitDaemon.init_client_dir(tmp_dir, "a")
File.write!(Path.join(client_a, "x.txt"), "from-a\n")
GitDaemon.git!(client_a, ["add", "x.txt"])
GitDaemon.git!(client_a, ["commit", "-m", "a"])
sha_a = GitDaemon.git!(client_a, ["rev-parse", "HEAD"])
client_b = GitDaemon.init_client_dir(tmp_dir, "b")
File.write!(Path.join(client_b, "x.txt"), "from-b\n")
GitDaemon.git!(client_b, ["add", "x.txt"])
GitDaemon.git!(client_b, ["commit", "-m", "b"])
sha_b = GitDaemon.git!(client_b, ["rev-parse", "HEAD"])
url = "git://127.0.0.1:#{port}/repo"
results =
[client_a, client_b]
|> Task.async_stream(
fn dir ->
GitDaemon.git_at(dir, ["push", url, "main:refs/heads/main"])
end,
timeout: 30_000,
max_concurrency: 2
)
|> Enum.map(fn {:ok, r} -> r end)
successes = Enum.count(results, fn {_out, code} -> code == 0 end)
assert successes >= 1, "no push succeeded:\n#{inspect(results)}"
{:ok, final} = Ref.get(repo, "refs/heads/main")
assert final in [sha_a, sha_b],
"final ref #{final} is neither sha_a=#{sha_a} nor sha_b=#{sha_b}"
after
stop.()
end
end
end
# --- fixtures ---
# Seed a ReceivePack daemon with a pre-existing repo that already has
# `refs/heads/main` and (optionally) `extra_refs` pointing at the
# initial commit. Returns `{port, stop_fn, repo}` so tests can also
# interact with the repo directly.
defp seeded_receive_pack(name, writes, opts \\ []) do
repo = make_linear_repo(name, writes)
for ref <- Keyword.get(opts, :extra_refs, []) do
{:ok, tip} = Ref.get(repo, "refs/heads/main")
:ok = Ref.put(repo, ref, tip, nil)
end
{port, stop} = GitDaemon.start_receive_pack(repo)
{port, stop, repo}
end
defp make_linear_repo(name, writes) do
repo = RepoHelper.memory_repo(name)
ExGitObjectstore.init(repo)
_ =
Enum.reduce(writes, nil, fn {file, content}, parent ->
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: file, sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: List.wrap(parent),
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: "c\n"
}
{:ok, sha} = Object.write(repo, commit)
if parent do
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
else
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
end
sha
end)
repo
end
end
test/ex_git_objectstore/integration/smart_http_test.exs +370 −0
@@ -1,0 +1,370 @@
# 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.SmartHttpTest do
@moduledoc """
End-to-end validation of the v2 protocol over HTTP smart transport.
HTTP is stateless per request, so the client ALWAYS sends `done` in
a single round. This exercises the exact protocol path that our
`git://` tests can't reliably trigger — the user-reported bug
(`expected 'acknowledgments', received 'packfile'`) hides here.
"""
use ExUnit.Case, async: false
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Protocol.PktLine
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.{GitDaemon, RepoHelper}
@moduletag :integration
@moduletag timeout: :timer.minutes(1)
describe "clone over HTTP smart protocol" do
@tag :tmp_dir
test "single-commit repo", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("http-clone-1", 1)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"http://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "http clone failed:\n#{out}"
assert File.exists?(Path.join(dest, "f.txt"))
after
stop.()
end
end
@tag :tmp_dir
test "multi-commit chain", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("http-clone-n", 5)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"http://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "http clone failed:\n#{out}"
{log, _} = GitDaemon.git_at(dest, ["log", "--oneline"])
assert length(String.split(String.trim(log), "\n")) == 5
after
stop.()
end
end
end
describe "fetch over HTTP smart protocol" do
# Regression path: HTTP is always stateless per request, so the
# client sends `done` in the first (and only) round. When the
# client has haves the server doesn't know, this is the exact
# production bug (`expected 'acknowledgments'`) our git://
# integration tests could not reliably reproduce.
@tag :tmp_dir
test "fetch with stale haves over HTTP (done=true + unmatched haves)",
%{tmp_dir: tmp_dir} do
# Two separate daemons on two ports. Client clones from daemon A,
# then we repoint `origin` to daemon B (different history) and
# fetch. Client's `have origin/main` is now unknown to B — the
# exact production bug we keep trying to catch.
repo_a = make_linear_repo("http-stale-a", 2)
repo_b = make_linear_repo("http-stale-b", 2)
{port_a, stop_a} = GitDaemon.start_http_smart(repo_a)
{port_b, stop_b} = GitDaemon.start_http_smart(repo_b)
try do
client = Path.join(tmp_dir, "client")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"http://127.0.0.1:#{port_a}/repo",
client
])
assert code == 0, "initial clone from A failed:\n#{out}"
# Point origin at B (different history, shares no commits with A).
GitDaemon.git!(client, [
"remote",
"set-url",
"origin",
"http://127.0.0.1:#{port_b}/repo"
])
# Force the client to include origin/main as a `have` via
# --negotiation-tip. Without this the HTTP v2 client often sends
# zero haves on a "new" remote even when it has tracking refs.
{fetch_out, fetch_code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"fetch",
"--negotiation-tip=refs/remotes/origin/main",
"origin",
"main"
])
refute String.contains?(fetch_out, "expected 'acknowledgments'"),
"server omitted acks section when haves were sent:\n#{fetch_out}"
refute String.contains?(fetch_out, "no 'ready'"),
"server sent NAK + packfile:\n#{fetch_out}"
assert fetch_code == 0, "http fetch failed (exit #{fetch_code}):\n#{fetch_out}"
after
stop_a.()
stop_b.()
end
end
@tag :tmp_dir
test "fetch after clone is a no-op (matching haves) over HTTP",
%{tmp_dir: tmp_dir} do
repo = make_linear_repo("http-noop", 3)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
client = Path.join(tmp_dir, "client")
{_, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"http://127.0.0.1:#{port}/repo",
client
])
assert code == 0
{out, code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"fetch",
"origin",
"main"
])
assert code == 0, "no-op fetch failed:\n#{out}"
after
stop.()
end
end
end
describe "push over HTTP smart protocol" do
@tag :tmp_dir
test "push a new branch", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("http-push")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
client = GitDaemon.init_client_dir(tmp_dir)
File.write!(Path.join(client, "a.txt"), "hi\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "first"])
{out, code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"push",
"http://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
assert code == 0, "http push failed:\n#{out}"
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, ^client_sha} = Ref.get(repo, "refs/heads/main")
after
stop.()
end
end
end
describe "direct HTTP POST (no git client)" do
# git's command-line client has elaborate negotiation heuristics
# that often avoid sending haves on a "new" remote — which means
# the failing protocol path is hard to force through real git
# invocations. This test hand-crafts the POST body so the
# `haves ≠ [] + done = true` case is exercised deterministically.
@tag :tmp_dir
@tag skip: "fails against current main (omit-acks bug); tracked in ex_git_objectstore PR #19"
test "POST /git-upload-pack with unmatched haves + done", _ctx do
repo = make_linear_repo("http-direct", 1)
{port, stop} = GitDaemon.start_http_smart(repo)
{:ok, server_sha} = Ref.get(repo, "refs/heads/main")
fake_have = String.duplicate("f", 40)
body =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{server_sha}") <>
PktLine.encode("have #{fake_have}") <>
PktLine.encode("done") <>
PktLine.flush()
try do
{:ok, response} =
http_post(
"http://127.0.0.1:#{port}/repo/git-upload-pack",
"application/x-git-upload-pack-request",
body,
[{"Git-Protocol", "version=2"}]
)
assert response.status == 200,
"unexpected HTTP status #{response.status}"
# The response body MUST include the `acknowledgments` section
# when haves were sent. Omitting it is the exact bug the last
# fix over-corrected into.
assert String.contains?(response.body, "acknowledgments"),
"server omitted the acks section when haves were sent (body: #{inspect(response.body, limit: 200)})"
assert String.contains?(response.body, "packfile"),
"server must still send a packfile with `done` (body: #{inspect(response.body, limit: 200)})"
after
stop.()
end
end
end
describe "large payloads" do
# Make sure Content-Length parsing and body reassembly on the server
# side handle a multi-KB POST without hanging or truncating.
@tag :tmp_dir
test "clone of a many-commit repo forces a large POST body on fetch",
%{tmp_dir: tmp_dir} do
repo = make_linear_repo("http-large", 60)
{port, stop} = GitDaemon.start_http_smart(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"http://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "large-repo clone failed:\n#{out}"
{log, _} = GitDaemon.git_at(dest, ["log", "--oneline"])
assert length(String.split(String.trim(log), "\n")) == 60
after
stop.()
end
end
end
# --- helpers ---
# Minimal HTTP POST using :httpc (built-in). Avoids adding a new dep
# just for one test helper.
defp http_post(url, content_type, body, extra_headers \\ []) do
:application.ensure_all_started(:inets)
headers =
Enum.map(extra_headers, fn {k, v} ->
{to_charlist(k), to_charlist(v)}
end)
request = {
to_charlist(url),
headers,
to_charlist(content_type),
body
}
case :httpc.request(:post, request, [timeout: 10_000], body_format: :binary) do
{:ok, {{_, status, _}, resp_headers, resp_body}} ->
{:ok,
%{
status: status,
headers: Map.new(resp_headers, fn {k, v} -> {to_string(k), to_string(v)} end),
body: resp_body
}}
{:error, reason} ->
{:error, reason}
end
end
# --- fixtures ---
defp make_linear_repo(name, n) do
repo = RepoHelper.memory_repo(name)
ExGitObjectstore.init(repo)
_ =
Enum.reduce(1..n, nil, fn i, parent ->
blob = Blob.from_content("v#{i}\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: List.wrap(parent),
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: "c#{i}\n"
}
{:ok, sha} = Object.write(repo, commit)
if parent do
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
else
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
end
sha
end)
repo
end
end
test/ex_git_objectstore/integration/upload_pack_v2_capabilities_test.exs +391 −0
@@ -1,0 +1,391 @@
# 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.UploadPackV2CapabilitiesTest do
@moduledoc """
Real-git-client validation of the capabilities UploadPackV2 advertises
(or should advertise) — one test per ls-refs / fetch option, so every
advertised capability has a client-facing assertion.
Tests that target a capability we don't yet implement are marked
`@tag skip: "..."` with a note on what's missing. They are live
tracking regressions for future work.
"""
use ExUnit.Case, async: false
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Protocol.{PktLine, UploadPackV2}
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.{GitDaemon, RepoHelper}
@moduletag :integration
@moduletag timeout: :timer.minutes(1)
describe "ls-refs" do
@tag :tmp_dir
@tag skip: "UploadPackV2 does not advertise HEAD nor resolve `symrefs` arg"
test "symrefs resolves HEAD to its target branch", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("symrefs", 1)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"ls-remote",
"--symref",
"git://127.0.0.1:#{port}/repo"
])
assert code == 0, "ls-remote failed:\n#{out}"
# The `--symref` output should show HEAD → refs/heads/main.
assert out =~ ~r/ref: refs\/heads\/main\s+HEAD/,
"expected HEAD symref in ls-remote output, got:\n#{out}"
after
stop.()
end
end
@tag :tmp_dir
@tag skip: "UploadPackV2.handle_ls_refs ignores the `peel` argument"
test "peel exposes annotated tag targets", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("peel")
ExGitObjectstore.init(repo)
commit_sha = simple_commit(repo, "f.txt", "v1\n", "initial\n", [])
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
tag = %Tag{
object: commit_sha,
type: "commit",
tag: "v1.0",
tagger: "T <t@t.com> 1000000000 +0000",
message: "release v1.0\n"
}
{:ok, tag_sha} = Object.write(repo, tag)
:ok = Ref.put(repo, "refs/tags/v1.0", tag_sha, nil)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"ls-remote",
"--tags",
"git://127.0.0.1:#{port}/repo"
])
assert code == 0, "ls-remote --tags failed:\n#{out}"
# Peeled entry lists `<commit_sha> refs/tags/v1.0^{}` alongside
# the tag object's own line.
assert out =~ ~r/#{tag_sha}\s+refs\/tags\/v1\.0$/m
assert out =~ ~r/#{commit_sha}\s+refs\/tags\/v1\.0\^\{\}$/m
after
stop.()
end
end
@tag :tmp_dir
@tag skip: "UploadPackV2 does not honour the `unborn` ls-refs arg"
test "unborn reports an unborn HEAD for an empty repo", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("unborn")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"ls-remote",
"--symref",
"git://127.0.0.1:#{port}/repo"
])
assert code == 0, "ls-remote failed:\n#{out}"
assert out =~ ~r/unborn\s+HEAD/,
"expected `unborn HEAD` for empty repo:\n#{out}"
after
stop.()
end
end
@tag :tmp_dir
test "clone of an empty repo produces an empty working tree", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("empty")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "empty-clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"git://127.0.0.1:#{port}/repo",
dest
])
# Either the clone succeeds into an empty repo, or it reports a
# clean "You appear to have cloned an empty repository." The
# important thing is: no protocol error, no crash.
refute code == 128,
"clone crashed with protocol error (exit 128):\n#{out}"
if code == 0 do
assert File.dir?(Path.join(dest, ".git"))
end
after
stop.()
end
end
end
describe "shallow fetch" do
@tag :tmp_dir
@tag skip: "fetch=shallow advertised but UploadPackV2 does not emit `shallow-info`"
test "--depth=1 clone returns only the tip commit", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("shallow-d1", 5)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "shallow")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--depth=1",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "shallow clone failed:\n#{out}"
{log, _} = GitDaemon.git_at(dest, ["log", "--oneline"])
assert length(String.split(String.trim(log), "\n")) == 1
after
stop.()
end
end
@tag :tmp_dir
@tag skip: "shallow-info section not implemented; deepen depends on it"
test "--deepen=3 after shallow clone pulls additional history",
%{tmp_dir: tmp_dir} do
repo = make_linear_repo("deepen", 5)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "shallow")
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--depth=1",
"git://127.0.0.1:#{port}/repo",
dest
])
{out, code} =
GitDaemon.git_at(dest, [
"-c",
"protocol.version=2",
"fetch",
"--deepen=3",
"origin",
"main"
])
assert code == 0, "deepen failed:\n#{out}"
{log, _} = GitDaemon.git_at(dest, ["log", "--oneline"])
assert length(String.split(String.trim(log), "\n")) >= 3
after
stop.()
end
end
end
describe "partial clone (--filter)" do
@tag :tmp_dir
@tag skip: "no `filter` capability advertised; UploadPackV2 always sends full pack"
test "--filter=blob:none produces a blobless pack", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("filter-blob", 3)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "blobless")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--filter=blob:none",
"--no-local",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "partial clone failed:\n#{out}"
# Git considers a blobless clone "partial" when the repo-local
# config has `remote.origin.promisor=true`. With a real filter
# server the config gets set automatically; here we assert the
# clone claims partial state.
{cfg, _} = GitDaemon.git_at(dest, ["config", "--get", "remote.origin.promisor"])
assert String.trim(cfg) == "true"
after
stop.()
end
end
@tag :tmp_dir
@tag skip: "no `filter` capability advertised; tree:0 shape will be rejected"
test "--filter=tree:0 produces a treeless pack", %{tmp_dir: tmp_dir} do
repo = make_linear_repo("filter-tree", 3)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "treeless")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"--filter=tree:0",
"--no-local",
"git://127.0.0.1:#{port}/repo",
dest
])
assert code == 0, "tree:0 clone failed:\n#{out}"
after
stop.()
end
end
end
describe "wait-for-done" do
@tag :tmp_dir
@tag skip: "wait-for-done capability not advertised by UploadPackV2"
test "client --negotiate-only uses wait-for-done when available",
%{tmp_dir: tmp_dir} do
repo = make_linear_repo("wfd", 3)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
client = Path.join(tmp_dir, "client")
GitDaemon.seed_client_clone("git://127.0.0.1:#{port}/repo", client)
{out, code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"fetch",
"--negotiate-only",
"--negotiation-tip=refs/heads/main",
"origin"
])
assert code == 0, "--negotiate-only failed:\n#{out}"
refute out =~ "server does not support wait-for-done",
"wait-for-done not advertised:\n#{out}"
after
stop.()
end
end
end
describe "capability advertisement contents" do
# This test is a canary over the advertisement. If we add a new
# capability (or drop one), it should be added/removed with a
# corresponding behaviour test in this file — this assertion forces
# that reminder.
test "advertisement lists the expected capabilities" do
repo = RepoHelper.memory_repo("advert")
ExGitObjectstore.init(repo)
{advert, _state} = UploadPackV2.init(repo)
expected = ["version 2", "ls-refs", "fetch=shallow", "server-option"]
{:ok, packets, ""} = PktLine.decode(advert)
lines = for {:data, d} <- packets, do: String.trim(d)
for cap <- expected do
assert cap in lines,
"capability #{inspect(cap)} missing from advertisement:\n#{inspect(lines)}"
end
end
end
# --- fixtures ---
defp make_linear_repo(name, n) do
writes = for i <- 1..n, do: {"f.txt", "v#{i}\n"}
repo = RepoHelper.memory_repo(name)
ExGitObjectstore.init(repo)
_ =
Enum.reduce(writes, nil, fn {file, content}, parent ->
sha = simple_commit(repo, file, content, "commit\n", List.wrap(parent))
if parent do
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
else
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
end
sha
end)
repo
end
defp simple_commit(repo, filename, content, message, parents) do
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: filename, sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: message
}
{:ok, sha} = Object.write(repo, commit)
sha
end
end
test/ex_git_objectstore/integration/upload_pack_v2_dataplane_test.exs +335 −0
@@ -1,0 +1,335 @@
# 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.UploadPackV2DataplaneTest do
@moduledoc """
Data-plane edge cases for UploadPackV2: packs large enough to force
sideband chunking, empty responses, many refs, abrupt client
disconnects, and TCP-boundary splits in the request.
"""
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.{GitDaemon, RepoHelper}
@moduletag :integration
@moduletag timeout: :timer.minutes(2)
describe "large packs" do
@tag :tmp_dir
test "clone of a pack > 64KB across multiple sideband frames",
%{tmp_dir: tmp_dir} do
# Construct a repo whose pack is large enough to need multiple
# sideband frames (PktLine chunk limit is ~64KB). ~80 commits with
# a 1KB blob apiece easily clears that on the wire.
repo = make_bulky_repo("bulk-64k", 80, 1024)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} = GitDaemon.git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "clone failed:\n#{out}"
# Sanity: the cloned repo has the expected commit count.
{log, _} = GitDaemon.git_at(dest, ["log", "--oneline"])
assert length(String.split(String.trim(log), "\n")) == 80
after
stop.()
end
end
@tag :tmp_dir
@tag :slow
test "clone of a pack > 1MB", %{tmp_dir: tmp_dir} do
# 200 commits with 8KB blobs ≈ well over 1 MB once packed.
repo = make_bulky_repo("bulk-1m", 200, 8 * 1024)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "clone")
{out, code} = GitDaemon.git_clone("git://127.0.0.1:#{port}/repo", dest)
assert code == 0, "clone failed:\n#{out}"
after
stop.()
end
end
end
describe "scale" do
@tag :tmp_dir
test "repo with 250 refs returns them all via ls-refs", %{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("many-refs")
ExGitObjectstore.init(repo)
tip = simple_commit(repo, "f.txt", "v1\n", [])
:ok = Ref.put(repo, "refs/heads/main", tip, nil)
for i <- 1..250 do
:ok = Ref.put(repo, "refs/heads/branch-#{i}", tip, nil)
end
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"ls-remote",
"--heads",
"git://127.0.0.1:#{port}/repo"
])
assert code == 0, "ls-remote failed:\n#{out}"
count =
out
|> String.split("\n", trim: true)
|> Enum.count(&String.contains?(&1, "refs/heads/"))
# 250 branches + main = 251
assert count == 251
after
stop.()
end
end
@tag :tmp_dir
test "ref-prefix filter applies server-side (doesn't return all refs)",
%{tmp_dir: tmp_dir} do
repo = RepoHelper.memory_repo("prefix")
ExGitObjectstore.init(repo)
tip = simple_commit(repo, "f.txt", "v1\n", [])
:ok = Ref.put(repo, "refs/heads/main", tip, nil)
:ok = Ref.put(repo, "refs/heads/feature/a", tip, nil)
:ok = Ref.put(repo, "refs/heads/feature/b", tip, nil)
:ok = Ref.put(repo, "refs/heads/hotfix/x", tip, nil)
:ok = Ref.put(repo, "refs/tags/v1.0", tip, nil)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
# Ask for just feature branches via ls-remote pattern; git will
# emit the ref-prefix arg for us.
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"ls-remote",
"git://127.0.0.1:#{port}/repo",
"refs/heads/feature/*"
])
assert code == 0, "ls-remote failed:\n#{out}"
assert out =~ "refs/heads/feature/a"
assert out =~ "refs/heads/feature/b"
refute out =~ "refs/heads/hotfix/x",
"ref-prefix filter leaked non-matching refs:\n#{out}"
after
stop.()
end
end
end
describe "abrupt client behaviour" do
@tag :tmp_dir
test "client closes socket immediately after sending request", %{tmp_dir: tmp_dir} do
_tmp_dir = tmp_dir
repo = make_bulky_repo("disconnect", 20, 1024)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
# Open a raw TCP connection, perform the v2 handshake + fetch,
# then close without reading the response. The daemon must not
# crash or leak the handler process.
pre_procs = :erlang.system_info(:process_count)
{:ok, socket} =
:gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :raw])
service_line =
PktLine.encode_raw("git-upload-pack /repo\0host=127.0.0.1\0\0version=2\0")
:ok = :gen_tcp.send(socket, service_line)
# Read advertisement (protocol v2 capability block).
{:ok, _advert} = :gen_tcp.recv(socket, 0, 5_000)
# Send a minimal fetch command for a bogus want; it's enough to
# get the server to start doing work. Then close immediately.
fetch =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{String.duplicate("0", 40)}") <>
PktLine.encode("done") <>
PktLine.flush()
:ok = :gen_tcp.send(socket, fetch)
:ok = :gen_tcp.close(socket)
# Give the spawned handler a moment to clean up.
Process.sleep(200)
post_procs = :erlang.system_info(:process_count)
# We accept up to a small jitter — other background processes
# may have spawned unrelated to our daemon.
assert post_procs - pre_procs <= 4,
"handler process leaked (pre=#{pre_procs}, post=#{post_procs})"
after
stop.()
end
end
end
describe "TCP split resilience (state machine level)" do
# Not a git-client test — this is a state-machine property check,
# but it belongs here because TCP-boundary bugs are how protocol
# framing issues escape pure-unit tests.
#
# Build a known-good fetch request as one blob, then feed it into
# UploadPackV2 sliced at arbitrary offsets for 20 different seeds.
# The concatenated response output MUST match the response from
# feeding the request as a single blob.
#
# Currently fails: `has_complete_command?` treats the intra-command
# delim (`0001`) as a terminator, so chunking that splits after
# delim but before flush causes premature processing. Tracked in
# https://anvil.fangorn.io/fangorn/ex_git_objectstore/issues/29
@tag skip: "issue #29 — has_complete_command? treats delim as terminator"
test "fetch request response is invariant under TCP chunking" do
repo = RepoHelper.memory_repo("split")
ExGitObjectstore.init(repo)
sha = simple_commit(repo, "f.txt", "hi\n", [])
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
request =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{sha}") <>
PktLine.encode("done") <>
PktLine.flush()
reference_response = feed_whole(repo, request)
for seed <- 1..20 do
chunks = random_splits(request, seed)
actual = feed_chunked(repo, chunks)
assert actual == reference_response, """
response differs from reference with seed #{seed}.
Chunk boundaries: #{inspect(Enum.map(chunks, &byte_size/1))}
"""
end
end
end
# --- helpers ---
defp feed_whole(repo, request) do
{_advert, state} = UploadPackV2.init(repo)
{response, _state} = UploadPackV2.feed(state, request)
response
end
defp feed_chunked(repo, chunks) do
{_advert, state} = UploadPackV2.init(repo)
{response, _state} =
Enum.reduce(chunks, {<<>>, state}, fn chunk, {acc, s} ->
{r, s2} = UploadPackV2.feed(s, chunk)
{acc <> r, s2}
end)
response
end
defp random_splits(binary, seed) do
:rand.seed(:exsplus, {seed, seed, seed})
size = byte_size(binary)
# 1..8 random cut points in [1, size)
cut_count = :rand.uniform(8)
cuts =
Enum.map(1..cut_count, fn _ -> :rand.uniform(size - 1) end)
|> Enum.sort()
|> Enum.uniq()
do_splits(binary, cuts, 0, [])
end
defp do_splits(binary, [], offset, acc) do
rest = binary_part(binary, offset, byte_size(binary) - offset)
Enum.reverse([rest | acc])
end
defp do_splits(binary, [cut | rest_cuts], offset, acc) do
chunk = binary_part(binary, offset, cut - offset)
do_splits(binary, rest_cuts, cut, [chunk | acc])
end
# --- fixtures ---
defp make_bulky_repo(name, commits, blob_bytes) do
repo = RepoHelper.memory_repo(name)
ExGitObjectstore.init(repo)
# Base content varies per-commit so the pack doesn't deduplicate
# everything to a single blob.
padding = String.duplicate("x", blob_bytes)
_ =
Enum.reduce(1..commits, nil, fn i, parent ->
content = "commit #{i}\n#{padding}"
sha = simple_commit(repo, "data.bin", content, List.wrap(parent), "c#{i}\n")
if parent do
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
else
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
end
sha
end)
repo
end
defp simple_commit(repo, filename, content, parents, message \\ "c\n") do
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: filename, sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: message
}
{:ok, sha} = Object.write(repo, commit)
sha
end
end
test/ex_git_objectstore/integration/upload_pack_v2_negotiation_test.exs +373 −0
@@ -1,0 +1,373 @@
# 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.UploadPackV2NegotiationTest do
@moduledoc """
Real-git-client validation of the negotiation paths through
`UploadPackV2`:
* stale haves (client knows commits the server doesn't)
* partial overlap (some haves match, some don't)
* multi-round (client does not send `done` in the first request)
* `--negotiate-only` (client wants ACKs only, no packfile)
* concurrent push + fetch on the same listener
Tests for protocol paths we don't yet handle correctly are marked
with `@tag skip: "..."` and carry a note about the missing feature
(and a tracking-issue link where applicable).
"""
use ExUnit.Case, async: false
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Ref
alias ExGitObjectstore.Test.{GitDaemon, RepoHelper}
@moduletag :integration
@moduletag timeout: :timer.minutes(1)
# --- tests ---
describe "stale haves (client knows commits the server doesn't)" do
# Reproduces the hephaestus / anvil regression: the client did a full
# clone against server A, then the remote URL was repointed (or repo
# rewritten) to a different-history server B. `git fetch` sends the
# client's local tip as a `have` — which the new server does not
# know — together with `done`. The server must still emit an
# `acknowledgments` section that ends with `ready`; this is the path
# that had both of our regressions.
@tag :tmp_dir
test "fetch succeeds when client's haves are all unknown to server",
%{tmp_dir: tmp_dir} do
repo_a = fresh_repo("stale-a", [{"a.txt", "a1\n"}, {"a.txt", "a2\n"}])
repo_b = fresh_repo("stale-b", [{"b.txt", "b1\n"}])
# Alternate: first connection serves repo A (for the clone); second
# and later connections serve repo B (so the fetch hits a different
# history).
counter = :counters.new(1, [:atomics])
repo_fun = fn ->
n = :counters.get(counter, 1)
:counters.add(counter, 1, 1)
if n == 0, do: repo_a, else: repo_b
end
{port, stop} = GitDaemon.start_upload_pack(repo_fun)
try do
client = Path.join(tmp_dir, "client")
GitDaemon.seed_client_clone("git://127.0.0.1:#{port}/repo", client)
# Now the backing repo is B; the client's `have` for origin/main
# (pointing into A's history) is unknown to B.
{out, code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"fetch",
"origin",
"main"
])
refute String.contains?(out, "expected 'acknowledgments'"),
"server omitted the acks section when haves were sent:\n#{out}"
refute String.contains?(out, "no 'ready'"),
"server sent `NAK` + packfile instead of ending acks with `ready`:\n#{out}"
assert code == 0,
"git fetch failed (exit #{code}):\n#{out}"
after
stop.()
end
end
# Forces `have <local_sha>` via --negotiation-tip + `done` via
# --depth=1. This is the most reliable single-round reproduction of
# the path above without needing two servers.
@tag :tmp_dir
test "shallow + negotiation-tip forces single-round stale-haves path",
%{tmp_dir: tmp_dir} do
repo = fresh_repo("stale-tip", [{"s.txt", "s1\n"}, {"s.txt", "s2\n"}])
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
client = GitDaemon.init_client_dir(tmp_dir, "client")
File.write!(Path.join(client, "local.txt"), "local\n")
GitDaemon.git!(client, ["add", "local.txt"])
GitDaemon.git!(client, ["commit", "-m", "local-only"])
{out, code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"fetch",
"--depth=1",
"--negotiation-tip=refs/heads/main",
"git://127.0.0.1:#{port}/repo",
"main"
])
refute String.contains?(out, "expected 'acknowledgments'"),
"server omitted the acks section when haves were sent:\n#{out}"
refute String.contains?(out, "no 'ready'"),
"server emitted `NAK` + packfile:\n#{out}"
assert code == 0,
"git fetch failed (exit #{code}):\n#{out}"
after
stop.()
end
end
end
describe "partial overlap (some haves match, some don't)" do
# Client clones, commits locally on a side branch (so it has extra
# commits the server lacks), and then re-fetches origin/main while
# advertising both tips. The server-side filter must ACK the common
# commits and ignore the unknown ones; fetch should succeed.
@tag :tmp_dir
test "fetch succeeds with mixed matching/unmatching haves",
%{tmp_dir: tmp_dir} do
repo =
fresh_repo("partial", [
{"f.txt", "a\n"},
{"f.txt", "b\n"},
{"f.txt", "c\n"}
])
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
client = Path.join(tmp_dir, "client")
GitDaemon.seed_client_clone("git://127.0.0.1:#{port}/repo", client)
# Add a local-only commit on a side branch.
GitDaemon.git!(client, ["checkout", "-b", "side"])
File.write!(Path.join(client, "side.txt"), "side\n")
GitDaemon.git!(client, ["add", "side.txt"])
GitDaemon.git!(client, ["commit", "-m", "local side"])
{out, code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"fetch",
"--negotiation-tip=refs/heads/side",
"origin",
"main"
])
assert code == 0,
"fetch with mixed haves failed (exit #{code}):\n#{out}"
after
stop.()
end
end
end
describe "multi-round negotiation" do
# The protocol-v2 default negotiator (`consecutive`) may choose to
# send haves across multiple round trips without `done` on the first
# request. Our `UploadPackV2` state machine terminates after the
# first fetch command, which breaks this case.
#
# This test locks in the intended behaviour: the fetch must succeed.
@tag :tmp_dir
test "deep divergence with consecutive negotiator completes", %{tmp_dir: tmp_dir} do
# 30 linear commits on the server — deep enough that the
# negotiator will typically issue more than one round.
server_commits =
for i <- 1..30, do: {"chain.txt", "v#{i}\n"}
repo = fresh_repo("multi-round", server_commits)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
client = Path.join(tmp_dir, "client")
GitDaemon.seed_client_clone("git://127.0.0.1:#{port}/repo", client)
# Make a deep local-only divergence on a side branch, forcing the
# negotiator to walk back through the history.
GitDaemon.git!(client, ["checkout", "-b", "side"])
for i <- 1..30 do
File.write!(Path.join(client, "side.txt"), "s#{i}\n")
GitDaemon.git!(client, ["add", "side.txt"])
GitDaemon.git!(client, ["commit", "-m", "side #{i}"])
end
{out, code} =
GitDaemon.git_at(
client,
[
"-c",
"protocol.version=2",
"-c",
"fetch.negotiationAlgorithm=consecutive",
"fetch",
"origin",
"main"
]
)
assert code == 0,
"multi-round fetch failed (exit #{code}):\n#{out}"
after
stop.()
end
end
end
describe "--negotiate-only" do
# `git fetch --negotiate-only` tells the server "tell me what we
# have in common, do not send a packfile". The server must emit an
# acks section ending with a flush (no `ready`, no packfile).
@tag :tmp_dir
@tag skip: "requires `wait-for-done` capability — not yet advertised by UploadPackV2"
test "negotiate-only returns ACKs without a packfile", %{tmp_dir: tmp_dir} do
repo =
fresh_repo("neg-only", [
{"f.txt", "v1\n"},
{"f.txt", "v2\n"}
])
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
client = Path.join(tmp_dir, "client")
GitDaemon.seed_client_clone("git://127.0.0.1:#{port}/repo", client)
{out, code} =
GitDaemon.git_at(client, [
"-c",
"protocol.version=2",
"fetch",
"--negotiate-only",
"--negotiation-tip=refs/heads/main",
"origin"
])
assert code == 0,
"--negotiate-only failed (exit #{code}):\n#{out}"
# Expect at least one `acknowledge ` line in stdout reporting
# a common commit.
assert out =~ "acknowledge",
"expected acknowledge output, got:\n#{out}"
after
stop.()
end
end
end
describe "concurrency" do
# Two concurrent clients on the same daemon port — one pushes, the
# other fetches. This doesn't verify snapshot isolation (the server
# has no transactional snapshot semantics on top of the storage
# backend), but it does ensure the daemon correctly serves
# independent connections without head-of-line blocking or shared
# mutable state leaking between them.
@tag :tmp_dir
test "concurrent fetch and push on the same port", %{tmp_dir: tmp_dir} do
repo =
fresh_repo("concurrent", [
{"f.txt", "v1\n"},
{"f.txt", "v2\n"}
])
{up_port, stop_up} = GitDaemon.start_upload_pack(repo)
{rp_port, stop_rp} = GitDaemon.start_receive_pack(repo)
try do
# Prepare a push client.
push_client = GitDaemon.init_client_dir(tmp_dir, "pusher")
File.write!(Path.join(push_client, "new.txt"), "new\n")
GitDaemon.git!(push_client, ["add", "new.txt"])
GitDaemon.git!(push_client, ["commit", "-m", "pushed"])
# Run both in parallel. `Task.async_stream` forces them onto
# separate processes.
results =
[
fn ->
dest = Path.join(tmp_dir, "clone")
GitDaemon.git_clone("git://127.0.0.1:#{up_port}/repo", dest)
end,
fn ->
GitDaemon.git_at(push_client, [
"-c",
"protocol.version=2",
"push",
"git://127.0.0.1:#{rp_port}/repo",
"main:refs/heads/pushed"
])
end
]
|> Task.async_stream(& &1.(), timeout: 30_000)
|> Enum.map(fn {:ok, result} -> result end)
for {out, code} <- results do
assert code == 0, "operation failed (exit #{code}):\n#{out}"
end
after
stop_up.()
stop_rp.()
end
end
end
# --- fixtures ---
# Build an in-memory repo with a linear chain of commits. `writes` is a
# list of `{filename, content}` tuples. Each becomes one commit.
defp fresh_repo(name, writes) do
repo = RepoHelper.memory_repo(name)
ExGitObjectstore.init(repo)
{_tip, parent} =
Enum.reduce(writes, {nil, nil}, fn {file, content}, {_prev_tree, parent} ->
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: file, sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
parents = if parent, do: [parent], else: []
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: "commit\n"
}
{:ok, sha} = Object.write(repo, commit)
if parent do
:ok = Ref.put(repo, "refs/heads/main", sha, parent)
else
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
end
{tree_sha, sha}
end)
repo
end
end
test/support/git_daemon.ex +501 −0
@@ -1,0 +1,501 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Test.GitDaemon do
@moduledoc """
Minimal `git://` daemon for integration tests.
Spins up a TCP listener on 127.0.0.1 that accepts `git://` service
handshake lines and routes each connection to either
`UploadPackV2` (for `git-upload-pack`, protocol v2) or `ReceivePack`
(for `git-receive-pack`).
Designed for test use only: each daemon serves a single repo (or a
caller-supplied `repo_fun/0` that can return different repos on each
connection, e.g. to simulate a repo being swapped behind a URL).
## Example
repo = RepoHelper.memory_repo()
{port, stop} = GitDaemon.start_upload_pack(repo)
on_exit(stop)
url = "git://127.0.0.1:" <> to_string(port) <> "/repo"
{out, code} = GitDaemon.git_clone(url, dest)
## Caveats
- Only protocol v2 is supported for upload-pack. A v0/v1 service line
causes the connection to be closed (no advertisement).
- The state machine terminates after a single command for upload-pack
(multi-round negotiation on the same TCP session will not work; the
client is expected to reconnect). This matches what HTTP-smart-style
clients do, and is the same behavior the production server exposes
in the failure modes we're targeting.
"""
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack, UploadPackV2}
# --- public API ---
@doc """
Start an upload-pack (fetch/clone) daemon. `repo_or_fun` may be
an `%ExGitObjectstore.Repo{}` or a zero-arity function that returns one
(called per-connection, useful for stale-haves scenarios).
Returns `{port, stop_fn}`.
"""
def start_upload_pack(repo_or_fun) do
repo_fun = as_fun(repo_or_fun)
start_daemon(fn client -> serve_upload_pack(client, repo_fun) end)
end
@doc """
Start a receive-pack (push) daemon. `opts` is forwarded to
`ReceivePack.init/2` (so you can wire up pre_receive_hook / update_hook
/ post_receive_hook for tests that need to exercise hook behavior).
Returns `{port, stop_fn}`.
"""
def start_receive_pack(repo_or_fun, opts \\ []) do
repo_fun = as_fun(repo_or_fun)
start_daemon(fn client -> serve_receive_pack(client, repo_fun, opts) end)
end
@doc """
Start a smart-HTTP daemon that serves both upload-pack and
receive-pack for a single repo.
Routes handled (path prefix is ignored — any URL path is accepted):
* `GET */info/refs?service=git-upload-pack`
* `POST */git-upload-pack` — body fed to `UploadPackV2`
* `GET */info/refs?service=git-receive-pack`
* `POST */git-receive-pack` — body fed to `ReceivePack`
Protocol v2 is used whenever the client sends
`Git-Protocol: version=2` (git does this by default on recent versions).
Returns `{port, stop_fn}`.
"""
def start_http_smart(repo_or_fun, opts \\ []) do
repo_fun = as_fun(repo_or_fun)
start_daemon(fn client -> serve_http(client, repo_fun, opts) end)
end
@doc """
`git clone` wrapper. Returns `{output, exit_code}`.
"""
def git_clone(url, dest, extra_args \\ []) do
git_at(nil, ["-c", "protocol.version=2", "clone"] ++ extra_args ++ [url, dest])
end
@doc """
Run `git <args>` in `dir` (or no `cd` if `dir` is nil). Env defaults
suppress password prompts and disable the keyring for non-interactive
runs. Returns `{output, exit_code}`.
"""
def git_at(dir, args, extra_env \\ []) do
env = [{"GIT_TERMINAL_PROMPT", "0"} | extra_env]
opts = [stderr_to_stdout: true, env: env]
opts = if dir, do: [{:cd, dir} | opts], else: opts
System.cmd("git", args, opts)
end
@doc """
Run `git <args>` in `dir` and raise if it fails. Returns trimmed stdout.
"""
def git!(dir, args, extra_env \\ []) do
{out, code} = git_at(dir, args, extra_env)
if code != 0 do
raise """
git #{Enum.join(args, " ")} failed (exit #{code}):
#{out}
"""
end
String.trim(out)
end
@doc """
Initialize a client-side git working dir with a local identity set.
Returns the dir path.
"""
def init_client_dir(tmp_dir, name \\ "client") do
dir = Path.join(tmp_dir, name)
File.mkdir_p!(dir)
git!(dir, ["init", "--initial-branch=main"])
git!(dir, ["config", "user.email", "t@t.com"])
git!(dir, ["config", "user.name", "t"])
git!(dir, ["config", "commit.gpgsign", "false"])
dir
end
@doc """
Clone `url` into `dest_dir` and raise on failure. Returns `dest_dir`.
"""
def seed_client_clone(url, dest_dir) do
parent = Path.dirname(dest_dir)
File.mkdir_p!(parent)
{out, code} = git_clone(url, dest_dir)
if code != 0, do: raise("seed_client_clone failed (#{code}):\n#{out}")
dest_dir
end
# --- internal ---
defp as_fun(fun) when is_function(fun, 0), do: fun
defp as_fun(repo), do: fn -> repo end
defp start_daemon(handler) 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, handler) end)
stop = fn -> :gen_tcp.close(listen) end
{port, stop}
end
defp accept_loop(listen, handler) do
case :gen_tcp.accept(listen, 30_000) do
{:ok, client} ->
spawn(fn -> handler.(client) end)
accept_loop(listen, handler)
{:error, :closed} ->
:ok
{:error, _} = err ->
err
end
end
# --- upload-pack connection ---
defp serve_upload_pack(client, repo_fun) do
with {:ok, service_line} <- read_one_pkt(client),
true <- service_is?(service_line, "git-upload-pack"),
true <- v2_requested?(service_line) do
repo = repo_fun.()
{advert, state} = UploadPackV2.init(repo)
:ok = :gen_tcp.send(client, advert)
drive_upload(client, state)
else
_ -> :ok
end
after
:gen_tcp.close(client)
end
defp drive_upload(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_upload(client, new_state)
end
{:error, _} ->
:ok
end
end
# --- receive-pack connection ---
defp serve_receive_pack(client, repo_fun, opts) do
with {:ok, service_line} <- read_one_pkt(client),
true <- service_is?(service_line, "git-receive-pack") do
repo = repo_fun.()
{advert, state} = ReceivePack.init(repo, opts)
:ok = :gen_tcp.send(client, advert)
drive_receive(client, state)
else
_ -> :ok
end
after
:gen_tcp.close(client)
end
defp drive_receive(client, state) do
if ReceivePack.done?(state) do
:ok
else
drive_receive_recv(client, state)
end
end
defp drive_receive_recv(client, state) do
case :gen_tcp.recv(client, 0, 15_000) do
{:ok, data} -> drive_receive_feed(client, state, data)
{:error, _} -> :ok
end
end
defp drive_receive_feed(client, state, data) do
{response, new_state} = ReceivePack.feed(state, data)
if byte_size(response) > 0, do: :ok = :gen_tcp.send(client, response)
drive_receive(client, new_state)
end
# --- shared ---
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 service_is?(line, svc), do: String.contains?(line, svc)
defp v2_requested?(line), do: String.contains?(line, "version=2")
# --- HTTP smart-protocol handler ---
defp serve_http(client, repo_fun, opts) do
case read_http_request(client) do
{:ok, %{method: "GET", path: path, headers: headers}} ->
handle_http_get(client, path, headers, repo_fun)
{:ok, %{method: "POST", path: path, headers: headers, body: body}} ->
handle_http_post(client, path, headers, body, repo_fun, opts)
_ ->
send_http(client, 400, "text/plain", "bad request")
end
after
:gen_tcp.close(client)
end
defp handle_http_get(client, path, _headers, repo_fun) do
cond do
String.contains?(path, "service=git-upload-pack") ->
send_info_refs(client, "git-upload-pack", repo_fun.(), :upload)
String.contains?(path, "service=git-receive-pack") ->
send_info_refs(client, "git-receive-pack", repo_fun.(), :receive)
true ->
send_http(client, 404, "text/plain", "not found")
end
end
defp handle_http_post(client, path, headers, body, repo_fun, opts) do
cond do
String.ends_with?(path, "/git-upload-pack") ->
handle_upload_pack_post(client, headers, body, repo_fun)
String.ends_with?(path, "/git-receive-pack") ->
handle_receive_pack_post(client, body, repo_fun, opts)
true ->
send_http(client, 404, "text/plain", "not found")
end
end
defp send_info_refs(client, service, repo, :upload) do
{advert, _state} = UploadPackV2.init(repo)
body = service_header_line(service) <> advert
send_http(client, 200, "application/x-#{service}-advertisement", body)
end
defp send_info_refs(client, service, repo, :receive) do
{advert, _state} = ReceivePack.init(repo)
body = service_header_line(service) <> advert
send_http(client, 200, "application/x-#{service}-advertisement", body)
end
defp service_header_line(service) do
# Smart-HTTP "service=" header line per Documentation/http-protocol.txt.
PktLine.encode("# service=#{service}\n") <> PktLine.flush()
end
defp handle_upload_pack_post(client, headers, body, repo_fun) do
repo = repo_fun.()
# Upload-pack POST is always stateless per request: advertisement
# isn't expected (client already got it via info/refs), just the
# command body. Protocol v2 clients include `Git-Protocol: version=2`.
if headers["git-protocol"] && String.contains?(headers["git-protocol"], "version=2") do
{_advert, state} = UploadPackV2.init(repo)
{response, _state} = UploadPackV2.feed(state, body)
send_http(client, 200, "application/x-git-upload-pack-result", response)
else
# v0/v1 not supported by this test daemon.
send_http(client, 400, "text/plain", "only protocol v2 supported")
end
end
defp handle_receive_pack_post(client, body, repo_fun, opts) do
repo = repo_fun.()
{_advert, state} = ReceivePack.init(repo, opts)
{response, _state} = drive_receive_state(body, state)
send_http(client, 200, "application/x-git-receive-pack-result", response)
end
defp drive_receive_state(body, state) do
{first, state} = ReceivePack.feed(state, body)
if ReceivePack.done?(state) do
{first, state}
else
# Some state machines accept the pack across multiple feeds; for
# HTTP the whole body arrives at once, so a second empty feed is
# enough to let maybe_process_pack run.
{second, state} = ReceivePack.feed(state, <<>>)
{first <> second, state}
end
end
# --- HTTP/1.1 mini-parser ---
defp read_http_request(client) do
with {:ok, request_line} <- read_line(client),
[method, path, _version] <- String.split(request_line, " ", parts: 3),
{:ok, headers} <- read_headers(client, %{}) do
build_http_request(client, method, path, headers)
else
err -> {:error, err}
end
end
defp build_http_request(_client, "GET", path, headers) do
{:ok, %{method: "GET", path: path, headers: headers}}
end
defp build_http_request(client, method, path, headers) do
case read_body(client, headers) do
{:ok, body} -> {:ok, %{method: method, path: path, headers: headers, body: body}}
err -> err
end
end
defp read_line(client, acc \\ <<>>) do
case :gen_tcp.recv(client, 1, 5_000) do
{:ok, <<?\r>>} ->
case :gen_tcp.recv(client, 1, 5_000) do
{:ok, <<?\n>>} -> {:ok, acc}
_ -> {:error, :bad_line_end}
end
{:ok, <<byte>>} ->
read_line(client, acc <> <<byte>>)
{:error, _} = err ->
err
end
end
defp read_headers(client, acc) do
case read_line(client) do
{:ok, ""} ->
{:ok, acc}
{:ok, line} ->
case String.split(line, ":", parts: 2) do
[k, v] ->
read_headers(client, Map.put(acc, String.downcase(String.trim(k)), String.trim(v)))
_ ->
read_headers(client, acc)
end
err ->
err
end
end
defp read_body(client, headers) do
cond do
len = headers["content-length"] ->
n = String.to_integer(len)
if n == 0, do: {:ok, <<>>}, else: :gen_tcp.recv(client, n, 15_000)
headers["transfer-encoding"] == "chunked" ->
read_chunked(client, <<>>)
true ->
{:ok, <<>>}
end
end
defp read_chunked(client, acc) do
with {:ok, size_line} <- read_line(client),
{size, _} <- Integer.parse(size_line, 16) do
read_chunked_body(client, acc, size)
else
_ -> {:error, :bad_chunk}
end
end
defp read_chunked_body(client, acc, 0) do
# Read the trailing CRLF after the last chunk.
_ = read_line(client)
{:ok, acc}
end
defp read_chunked_body(client, acc, size) do
with {:ok, chunk} <- :gen_tcp.recv(client, size, 15_000),
{:ok, _} <- read_line(client) do
read_chunked(client, acc <> chunk)
end
end
defp send_http(client, status, content_type, body) do
status_text =
case status do
200 -> "OK"
400 -> "Bad Request"
404 -> "Not Found"
_ -> "OK"
end
headers = [
"HTTP/1.1 #{status} #{status_text}\r\n",
"Content-Type: #{content_type}\r\n",
"Content-Length: #{byte_size(body)}\r\n",
"Cache-Control: no-cache\r\n",
"Connection: close\r\n",
"\r\n"
]
:gen_tcp.send(client, [headers, body])
end
end