ref:4dd9e11aa8545cb03121025053d1a2a06e8ba62c

test: add protocol interop tests with real git CLI packs (closes #8)

16 integration tests that exercise ReceivePack and UploadPack with real git-generated pack data (delta compression, thin packs, binary files, merge commits, force push, multi-branch, annotated tags, round-trip verification, adversarial packs). Also fix credo issues in diff module. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 4dd9e11aa8545cb03121025053d1a2a06e8ba62c
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-14 02:52
Parents: b18689e
3 files changed +871 -42
Type
lib/ex_git_objectstore/diff.ex +23 −21
@@ -94,24 +94,24 @@
with {:ok, old_commit} <- ObjectResolver.read(repo, old_commit_sha),
{:ok, new_commit} <- ObjectResolver.read(repo, new_commit_sha),
{:ok, changes} <- diff_trees(repo, old_commit.tree, new_commit.tree) do
file_diffs =
Enum.map(changes, fn change ->
case diff_blobs(repo, change.old_sha, change.new_sha, opts) do
{:ok, :binary, sizes} ->
%{
path: change.path,
status: change.status,
hunks: [],
binary: true,
old_size: sizes.old_size,
new_size: sizes.new_size
}
file_diffs = Enum.map(changes, &build_file_diff(repo, &1, opts))
{:ok, file_diffs}
end
end
defp build_file_diff(repo, change, opts) do
{:ok, hunks} ->
%{path: change.path, status: change.status, hunks: hunks}
end
end)
case diff_blobs(repo, change.old_sha, change.new_sha, opts) do
{:ok, :binary, sizes} ->
%{
path: change.path,
status: change.status,
hunks: [],
binary: true,
old_size: sizes.old_size,
new_size: sizes.new_size
}
{:ok, file_diffs}
{:ok, hunks} ->
%{path: change.path, status: change.status, hunks: hunks}
end
end
@@ -321,11 +321,13 @@
|> merge_ranges()
# Build a hunk from each merged range
Enum.map(ranges, fn {start_idx, end_idx} ->
lines = for i <- start_idx..end_idx, do: :array.get(i, indexed_vec)
build_hunk(lines)
end)
Enum.map(ranges, &range_to_hunk(&1, indexed_vec))
end
end
defp range_to_hunk({start_idx, end_idx}, indexed_vec) do
lines = for i <- start_idx..end_idx, do: :array.get(i, indexed_vec)
build_hunk(lines)
end
defp merge_ranges([]), do: []
test/ex_git_objectstore/diff/diff_test.exs +9 −21
@@ -151,20 +151,16 @@
# 20-line file with changes at line 2 and line 19 (far apart)
old_lines =
Enum.map(1..20, fn n -> "line#{n}" end)
|> Enum.join("\n")
|> Kernel.<>("\n")
Enum.map_join(1..20, "\n", fn n -> "line#{n}" end) <> "\n"
new_lines =
Enum.map(1..20, fn n ->
Enum.map_join(1..20, "\n", fn n ->
case n do
2 -> "CHANGED2"
19 -> "CHANGED19"
_ -> "line#{n}"
end
end)
end) <> "\n"
|> Enum.join("\n")
|> Kernel.<>("\n")
old = Blob.from_content(old_lines)
{:ok, old_sha} = Object.write(repo, old)
@@ -198,20 +194,16 @@
# Changes at lines 3 and 7 — with context=3, their context windows overlap
old_lines =
Enum.map(1..10, fn n -> "line#{n}" end)
Enum.map_join(1..10, "\n", fn n -> "line#{n}" end) <> "\n"
|> Enum.join("\n")
|> Kernel.<>("\n")
new_lines =
Enum.map(1..10, fn n ->
Enum.map_join(1..10, "\n", fn n ->
case n do
3 -> "CHANGED3"
7 -> "CHANGED7"
_ -> "line#{n}"
end
end)
|> Enum.join("\n")
|> Kernel.<>("\n")
end) <> "\n"
old = Blob.from_content(old_lines)
{:ok, old_sha} = Object.write(repo, old)
@@ -230,16 +222,12 @@
# Single change at line 10 in a 20-line file
old_lines =
Enum.map(1..20, fn n -> "line#{n}" end)
|> Enum.join("\n")
Enum.map_join(1..20, "\n", fn n -> "line#{n}" end) <> "\n"
|> Kernel.<>("\n")
new_lines =
Enum.map(1..20, fn n ->
Enum.map_join(1..20, "\n", fn n ->
if n == 10, do: "CHANGED", else: "line#{n}"
end) <> "\n"
end)
|> Enum.join("\n")
|> Kernel.<>("\n")
old = Blob.from_content(old_lines)
{:ok, old_sha} = Object.write(repo, old)
test/ex_git_objectstore/integration/protocol_interop_test.exs +839 −0
@@ -1,0 +1,839 @@
# 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.ProtocolInteropTest do
@moduledoc """
Integration tests that exercise ReceivePack and UploadPack with real
git CLI-generated pack data. These tests catch protocol bugs that
synthetic (Writer-generated) packs cannot trigger — delta compression,
thin packs, OFS_DELTA, and complex object graphs.
Requires `git` to be installed on the system.
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.ObjectResolver
alias ExGitObjectstore.Pack.Reader
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack, UploadPack}
alias ExGitObjectstore.{Ref, Repo}
alias ExGitObjectstore.Storage.Filesystem
@moduletag :integration
@moduletag timeout: :timer.minutes(2)
@zero_sha String.duplicate("0", 40)
setup do
tmp_dir = System.tmp_dir!()
base = Path.join(tmp_dir, "protocol_interop_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(base)
# Create a working git repo for generating real packs
work_dir = Path.join(base, "work")
File.mkdir_p!(work_dir)
git!(work_dir, ["init"])
git!(work_dir, ["config", "user.email", "test@example.com"])
git!(work_dir, ["config", "user.name", "Test User"])
on_exit(fn -> File.rm_rf!(base) end)
%{base: base, work_dir: work_dir}
end
# ============================================================================
# ReceivePack with real git packs
# ============================================================================
describe "ReceivePack with real git packs" do
test "push single commit with git pack-objects output", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create a real commit
File.write!(Path.join(work_dir, "hello.txt"), "Hello, World!\n")
git!(work_dir, ["add", "hello.txt"])
git!(work_dir, ["commit", "-m", "Initial commit"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
# Create target repo and push
repo = make_filesystem_repo(base, "target1")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
# Verify objects are readable
{:ok, commit_obj} = ObjectResolver.read(repo, commit_sha)
assert %Commit{} = commit_obj
assert commit_obj.message == "Initial commit\n"
# Verify blob content
{:ok, tree_obj} = ObjectResolver.read(repo, commit_obj.tree)
assert %Tree{entries: [entry]} = tree_obj
assert entry.name == "hello.txt"
{:ok, blob_obj} = ObjectResolver.read(repo, entry.sha)
assert %Blob{content: "Hello, World!\n"} = blob_obj
end
test "push repo with many files (triggers delta packing)", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create 50+ similar files to trigger git's delta compression
for i <- 1..60 do
content = "# Module #{i}\ndefmodule Mod#{i} do\n def run, do: :ok\nend\n"
File.write!(Path.join(work_dir, "mod_#{i}.ex"), content)
end
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Add 60 modules"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
# Real pack with deltas should be significantly smaller than total content
assert length(shas) >= 62
repo = make_filesystem_repo(base, "target_many")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
# Verify all blobs are accessible
{:ok, commit_obj} = ObjectResolver.read(repo, commit_sha)
{:ok, tree_obj} = ObjectResolver.read(repo, commit_obj.tree)
assert length(tree_obj.entries) == 60
for entry <- tree_obj.entries do
{:ok, blob} = ObjectResolver.read(repo, entry.sha)
assert %Blob{} = blob
assert byte_size(blob.content) > 0
end
end
test "push repo with binary files", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create binary content that can't be delta-compressed well
binary_content = :crypto.strong_rand_bytes(1024)
File.write!(Path.join(work_dir, "image.bin"), binary_content)
File.write!(Path.join(work_dir, "readme.txt"), "A project with binary files\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Add binary file"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "target_binary")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
# Verify binary content is byte-for-byte correct
{:ok, commit_obj} = ObjectResolver.read(repo, commit_sha)
{:ok, tree_obj} = ObjectResolver.read(repo, commit_obj.tree)
bin_entry = Enum.find(tree_obj.entries, &(&1.name == "image.bin"))
{:ok, blob} = ObjectResolver.read(repo, bin_entry.sha)
assert blob.content == binary_content
end
test "incremental push (thin pack with delta bases in repo)", ctx do
%{work_dir: work_dir, base: base} = ctx
# First commit
File.write!(Path.join(work_dir, "file.txt"), "version 1\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "First commit"])
c1_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Push first commit (full pack)
shas1 = all_object_shas(work_dir, "HEAD")
pack1 = git_pack_objects(work_dir, shas1)
repo = make_filesystem_repo(base, "target_incr")
{_advert, state} = ReceivePack.init(repo)
commands1 = build_receive_commands([{@zero_sha, c1_sha, "refs/heads/main"}])
{response1, _state} = ReceivePack.feed(state, commands1 <> pack1)
assert_report_ok(response1, ["refs/heads/main"])
# Second commit (modifies the file — thin pack references first blob as delta base)
File.write!(Path.join(work_dir, "file.txt"), "version 2\nwith more content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Second commit"])
c2_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Generate thin pack: only objects new since c1
pack2 = git_pack_objects_thin(work_dir, "HEAD", "HEAD~1")
# Push incremental update
{_advert2, state2} = ReceivePack.init(repo)
commands2 = build_receive_commands([{c1_sha, c2_sha, "refs/heads/main"}])
{response2, state2} = ReceivePack.feed(state2, commands2 <> pack2)
assert ReceivePack.done?(state2)
assert_report_ok(response2, ["refs/heads/main"])
# Verify both commits readable
{:ok, c2_obj} = ObjectResolver.read(repo, c2_sha)
assert c2_obj.message == "Second commit\n"
assert c2_obj.parents == [c1_sha]
{:ok, c2_tree} = ObjectResolver.read(repo, c2_obj.tree)
file_entry = Enum.find(c2_tree.entries, &(&1.name == "file.txt"))
{:ok, blob} = ObjectResolver.read(repo, file_entry.sha)
assert blob.content == "version 2\nwith more content\n"
end
test "multi-branch push", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create main branch
File.write!(Path.join(work_dir, "main.txt"), "main content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Main commit"])
main_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Create feature branch
git!(work_dir, ["checkout", "-b", "feature"])
File.write!(Path.join(work_dir, "feature.txt"), "feature content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Feature commit"])
feature_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Pack all objects across both branches
shas = all_object_shas(work_dir, "--all")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "target_multi")
{_advert, state} = ReceivePack.init(repo)
commands =
build_receive_commands([
{@zero_sha, main_sha, "refs/heads/main"},
{@zero_sha, feature_sha, "refs/heads/feature"}
])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main", "refs/heads/feature"])
# Verify both refs
assert {:ok, ^main_sha} = Ref.get(repo, "refs/heads/main")
assert {:ok, ^feature_sha} = Ref.get(repo, "refs/heads/feature")
end
test "force push (history rewrite)", ctx do
%{work_dir: work_dir, base: base} = ctx
# First commit
File.write!(Path.join(work_dir, "file.txt"), "original\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Original"])
c1_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Push first commit
shas1 = all_object_shas(work_dir, "HEAD")
pack1 = git_pack_objects(work_dir, shas1)
repo = make_filesystem_repo(base, "target_force")
{_advert, state} = ReceivePack.init(repo)
cmd1 = build_receive_commands([{@zero_sha, c1_sha, "refs/heads/main"}])
{r1, _} = ReceivePack.feed(state, cmd1 <> pack1)
assert_report_ok(r1, ["refs/heads/main"])
# Amend commit (rewrites history — different SHA for same ref)
File.write!(Path.join(work_dir, "file.txt"), "rewritten\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "--amend", "-m", "Rewritten"])
c2_sha = git!(work_dir, ["rev-parse", "HEAD"])
refute c1_sha == c2_sha
shas2 = all_object_shas(work_dir, "HEAD")
pack2 = git_pack_objects(work_dir, shas2)
# Force push: old_sha=c1, new_sha=c2 (non-fast-forward)
{_advert2, state2} = ReceivePack.init(repo)
cmd2 = build_receive_commands([{c1_sha, c2_sha, "refs/heads/main"}])
{r2, state2} = ReceivePack.feed(state2, cmd2 <> pack2)
assert ReceivePack.done?(state2)
assert_report_ok(r2, ["refs/heads/main"])
assert {:ok, ^c2_sha} = Ref.get(repo, "refs/heads/main")
{:ok, commit_obj} = ObjectResolver.read(repo, c2_sha)
assert commit_obj.message == "Rewritten\n"
end
test "push with merge commits (complex graph)", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create main with initial commit
File.write!(Path.join(work_dir, "main.txt"), "main\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Initial on main"])
# Create branch and add a file
git!(work_dir, ["checkout", "-b", "feature"])
File.write!(Path.join(work_dir, "feature.txt"), "feature work\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Feature work"])
# Back to main, add another file
git!(work_dir, ["checkout", "main"])
File.write!(Path.join(work_dir, "main2.txt"), "more main work\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "More main work"])
# Merge feature into main
git!(work_dir, ["merge", "feature", "-m", "Merge feature"])
merge_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "--all")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "target_merge")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, merge_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
# Verify merge commit has two parents
{:ok, merge_obj} = ObjectResolver.read(repo, merge_sha)
assert length(merge_obj.parents) == 2
assert merge_obj.message =~ "Merge"
end
test "push annotated tags", ctx do
%{work_dir: work_dir, base: base} = ctx
File.write!(Path.join(work_dir, "file.txt"), "v1.0\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Release 1.0"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
git!(work_dir, ["tag", "-a", "v1.0", "-m", "Version 1.0 release"])
tag_sha = git!(work_dir, ["rev-parse", "v1.0"])
shas = all_object_shas(work_dir, "--all")
# Also include the tag object itself
tag_obj_sha = git!(work_dir, ["rev-parse", "v1.0^{tag}"]) |> String.trim()
shas = Enum.uniq([tag_obj_sha | shas])
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "target_tags")
{_advert, state} = ReceivePack.init(repo)
commands =
build_receive_commands([
{@zero_sha, commit_sha, "refs/heads/main"},
{@zero_sha, tag_sha, "refs/tags/v1.0"}
])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main", "refs/tags/v1.0"])
assert {:ok, ^tag_sha} = Ref.get(repo, "refs/tags/v1.0")
end
end
# ============================================================================
# UploadPack output verification with git index-pack
# ============================================================================
describe "UploadPack output verified by git index-pack" do
test "git index-pack can parse UploadPack output for single commit", ctx do
%{work_dir: work_dir, base: base} = ctx
# Push content into our repo first
File.write!(Path.join(work_dir, "hello.txt"), "Hello!\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Hello"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "upload_single")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = ReceivePack.feed(state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Now fetch via UploadPack
{_advert, up_state} = UploadPack.init(repo)
client_data =
PktLine.encode("want #{commit_sha}") <>
PktLine.flush() <>
PktLine.encode("done")
{response, up_state} = UploadPack.feed(up_state, client_data)
assert UploadPack.done?(up_state)
# Extract pack from sideband response
fetched_pack = extract_sideband_pack(response)
assert fetched_pack != nil
# Verify git can parse it
git_index_pack!(base, fetched_pack)
end
test "git index-pack can parse UploadPack output for multi-file repo", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create repo with subdirectories
File.mkdir_p!(Path.join(work_dir, "src"))
File.mkdir_p!(Path.join(work_dir, "test"))
File.write!(Path.join(work_dir, "src/app.ex"), "defmodule App do\nend\n")
File.write!(Path.join(work_dir, "test/app_test.ex"), "defmodule AppTest do\nend\n")
File.write!(Path.join(work_dir, "README.md"), "# My Project\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Project scaffold"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "upload_multi")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = ReceivePack.feed(state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Fetch via UploadPack
{_advert, up_state} = UploadPack.init(repo)
client_data =
PktLine.encode("want #{commit_sha}") <>
PktLine.flush() <>
PktLine.encode("done")
{response, up_state} = UploadPack.feed(up_state, client_data)
assert UploadPack.done?(up_state)
fetched_pack = extract_sideband_pack(response)
assert fetched_pack != nil
git_index_pack!(base, fetched_pack)
# Also verify our own reader can parse it
assert {:ok, entries} = Reader.parse(fetched_pack)
types = Enum.map(entries, & &1.type) |> MapSet.new()
assert :commit in types
assert :tree in types
assert :blob in types
end
end
# ============================================================================
# Round-trip: push through ReceivePack → fetch via UploadPack → verify
# ============================================================================
describe "round-trip: ReceivePack → UploadPack" do
test "push content, fetch it back, verify content matches", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create repo with diverse content
File.write!(
Path.join(work_dir, "code.ex"),
"defmodule Code do\n def hello, do: :world\nend\n"
)
File.write!(Path.join(work_dir, "data.txt"), "Some data\nWith multiple lines\n")
binary = :crypto.strong_rand_bytes(256)
File.write!(Path.join(work_dir, "binary.dat"), binary)
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Mixed content"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Push into our repo
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "roundtrip")
{_advert, rp_state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = ReceivePack.feed(rp_state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Fetch back via UploadPack
{_advert, up_state} = UploadPack.init(repo)
client_data =
PktLine.encode("want #{commit_sha}") <>
PktLine.flush() <>
PktLine.encode("done")
{response, _} = UploadPack.feed(up_state, client_data)
fetched_pack = extract_sideband_pack(response)
assert fetched_pack != nil
{:ok, entries} = Reader.parse(fetched_pack)
# Find blobs and verify content
blob_entries = Enum.filter(entries, &(&1.type == :blob))
blob_contents = MapSet.new(blob_entries, & &1.data)
assert "defmodule Code do\n def hello, do: :world\nend\n" in blob_contents
assert "Some data\nWith multiple lines\n" in blob_contents
assert binary in blob_contents
end
test "push multiple branches, fetch each, verify", ctx do
%{work_dir: work_dir, base: base} = ctx
# Create main
File.write!(Path.join(work_dir, "main.txt"), "main content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Main"])
main_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Create feature
git!(work_dir, ["checkout", "-b", "feature"])
File.write!(Path.join(work_dir, "feature.txt"), "feature content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Feature"])
feature_sha = git!(work_dir, ["rev-parse", "HEAD"])
# Push both
git!(work_dir, ["checkout", "main"])
shas = all_object_shas(work_dir, "--all")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "roundtrip_multi")
{_advert, rp_state} = ReceivePack.init(repo)
commands =
build_receive_commands([
{@zero_sha, main_sha, "refs/heads/main"},
{@zero_sha, feature_sha, "refs/heads/feature"}
])
{r, _} = ReceivePack.feed(rp_state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main", "refs/heads/feature"])
# Fetch feature branch via UploadPack
{_advert, up_state} = UploadPack.init(repo)
client_data =
PktLine.encode("want #{feature_sha}") <>
PktLine.flush() <>
PktLine.encode("done")
{response, up_state} = UploadPack.feed(up_state, client_data)
assert UploadPack.done?(up_state)
fetched_pack = extract_sideband_pack(response)
{:ok, entries} = Reader.parse(fetched_pack)
# Should contain both commits (feature depends on main)
commit_entries = Enum.filter(entries, &(&1.type == :commit))
assert length(commit_entries) >= 2
end
end
# ============================================================================
# Adversarial packs
# ============================================================================
describe "adversarial packs" do
test "corrupted pack checksum is rejected (stays incomplete)", ctx do
%{work_dir: work_dir, base: base} = ctx
File.write!(Path.join(work_dir, "file.txt"), "content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Test"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
# Corrupt the trailing checksum (last 20 bytes)
body_len = byte_size(pack_data) - 20
<<body::binary-size(body_len), _checksum::binary-size(20)>> = pack_data
corrupted = body <> :crypto.strong_rand_bytes(20)
repo = make_filesystem_repo(base, "target_corrupt")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> corrupted)
# Corrupted checksum means pack appears incomplete — state machine waits for more data
refute ReceivePack.done?(state)
assert response == <<>>
assert state.phase == :pack
end
test "truncated pack data is rejected (stays incomplete)", ctx do
%{work_dir: work_dir, base: base} = ctx
File.write!(Path.join(work_dir, "file.txt"), "content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Test"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
# Truncate the pack (remove last 30 bytes)
truncated = binary_part(pack_data, 0, byte_size(pack_data) - 30)
repo = make_filesystem_repo(base, "target_trunc")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> truncated)
# Truncated pack won't have valid checksum — stays incomplete
refute ReceivePack.done?(state)
assert response == <<>>
end
end
# ============================================================================
# Protocol edge cases
# ============================================================================
describe "protocol edge cases" do
test "delete ref via ReceivePack", ctx do
%{work_dir: work_dir, base: base} = ctx
# First push a branch
File.write!(Path.join(work_dir, "file.txt"), "content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Init"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "target_delete")
{_advert, state} = ReceivePack.init(repo)
cmd1 =
build_receive_commands([
{@zero_sha, commit_sha, "refs/heads/main"},
{@zero_sha, commit_sha, "refs/heads/to-delete"}
])
{r1, _} = ReceivePack.feed(state, cmd1 <> pack_data)
assert_report_ok(r1, ["refs/heads/main", "refs/heads/to-delete"])
# Now delete the branch (no pack needed for delete-only)
{_advert2, state2} = ReceivePack.init(repo)
cmd2 = build_receive_commands([{commit_sha, @zero_sha, "refs/heads/to-delete"}])
{r2, state2} = ReceivePack.feed(state2, cmd2)
assert ReceivePack.done?(state2)
assert_report_ok(r2, ["refs/heads/to-delete"])
assert {:error, _} = Ref.get(repo, "refs/heads/to-delete")
assert {:ok, ^commit_sha} = Ref.get(repo, "refs/heads/main")
end
test "UploadPack advertisement format matches git expectations", ctx do
%{work_dir: work_dir, base: base} = ctx
File.write!(Path.join(work_dir, "file.txt"), "content\n")
git!(work_dir, ["add", "."])
git!(work_dir, ["commit", "-m", "Init"])
commit_sha = git!(work_dir, ["rev-parse", "HEAD"])
shas = all_object_shas(work_dir, "HEAD")
pack_data = git_pack_objects(work_dir, shas)
repo = make_filesystem_repo(base, "advert_check")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = ReceivePack.feed(state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Now check UploadPack advertisement
{advert, _state} = UploadPack.init(repo)
{:ok, packets, ""} = PktLine.decode(advert)
data_lines = for {:data, d} <- packets, do: d
assert List.last(packets) == :flush
# First line should have HEAD with capabilities after NUL
first = hd(data_lines)
assert String.contains?(first, "\0")
[ref_part, caps_part] = String.split(first, "\0", parts: 2)
assert String.contains?(ref_part, commit_sha)
assert String.contains?(ref_part, "HEAD")
refute String.starts_with?(caps_part, " ")
assert String.contains?(caps_part, "side-band-64k")
end
end
# ============================================================================
# Helpers
# ============================================================================
defp git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
if status != 0, do: raise("git #{Enum.join(args, " ")} failed: #{output}")
String.trim(output)
end
defp all_object_shas(work_dir, rev_spec) do
output = git!(work_dir, ["rev-list", "--objects", rev_spec])
output
|> String.split("\n", trim: true)
|> Enum.map(fn line ->
line |> String.split(" ", parts: 2) |> List.first()
end)
end
defp git_pack_objects(work_dir, shas) do
sha_input = Enum.join(shas, "\n") <> "\n"
sha_file = Path.join(work_dir, ".pack_shas")
File.write!(sha_file, sha_input)
{output, status} =
System.cmd("sh", ["-c", "git pack-objects --stdout < .pack_shas"], cd: work_dir)
if status != 0, do: raise("git pack-objects failed: #{output}")
output
end
defp git_pack_objects_thin(work_dir, include_rev, exclude_rev) do
rev_input = "#{include_rev}\n^#{exclude_rev}\n"
rev_file = Path.join(work_dir, ".pack_revs")
File.write!(rev_file, rev_input)
{output, status} =
System.cmd("sh", ["-c", "git pack-objects --stdout --thin --revs < .pack_revs"],
cd: work_dir
)
if status != 0, do: raise("git pack-objects --thin failed: #{output}")
output
end
defp make_filesystem_repo(base, name) do
repo_dir = Path.join([base, "repos", name])
File.mkdir_p!(repo_dir)
repo = Repo.new(name, storage: {Filesystem, %{root: base}})
ExGitObjectstore.init(repo)
repo
end
defp build_receive_commands(commands) do
{first, rest} =
case commands do
[first | rest] -> {first, rest}
[] -> raise "need at least one command"
end
{old, new, ref} = first
first_line = PktLine.encode_raw("#{old} #{new} #{ref}\0report-status\n")
rest_lines =
Enum.map(rest, fn {old, new, ref} ->
PktLine.encode("#{old} #{new} #{ref}")
end)
IO.iodata_to_binary([first_line | rest_lines] ++ [PktLine.flush()])
end
defp assert_report_ok(response, expected_refs) do
{:ok, packets, _} = PktLine.decode(response)
data_lines = for {:data, d} <- packets, do: d
assert "unpack ok" in data_lines,
"Expected 'unpack ok' in report, got: #{inspect(data_lines)}"
for ref <- expected_refs do
assert "ok #{ref}" in data_lines,
"Expected 'ok #{ref}' in report, got: #{inspect(data_lines)}"
end
end
defp git_index_pack!(base, pack_data) do
pack_file = Path.join(base, "test_#{:erlang.unique_integer([:positive])}.pack")
File.write!(pack_file, pack_data)
{output, status} = System.cmd("git", ["index-pack", pack_file], stderr_to_stdout: true)
if status != 0 do
flunk("git index-pack failed (exit #{status}): #{output}")
end
# Clean up .idx file that git creates
File.rm(String.replace_suffix(pack_file, ".pack", ".idx"))
File.rm(pack_file)
end
# Extract pack data from sideband-encoded pkt-line response.
defp extract_sideband_pack(response) do
extract_sideband_loop(response, <<>>)
end
defp extract_sideband_loop(<<>>, acc), do: if(acc == <<>>, do: nil, else: acc)
defp extract_sideband_loop(<<"0000", _rest::binary>>, acc) do
if acc == <<>>, do: nil, else: acc
end
defp extract_sideband_loop(<<hex::binary-size(4), rest::binary>>, acc) do
case Integer.parse(hex, 16) do
{len, ""} when len >= 5 ->
extract_pkt_payload(rest, len - 4, acc)
_ ->
if acc == <<>>, do: nil, else: acc
end
end
defp extract_sideband_loop(_, acc), do: if(acc == <<>>, do: nil, else: acc)
defp extract_pkt_payload(rest, payload_len, acc) when byte_size(rest) >= payload_len do
<<payload::binary-size(payload_len), after_pkt::binary>> = rest
case payload do
<<1, pack_chunk::binary>> ->
extract_sideband_loop(after_pkt, acc <> pack_chunk)
_ ->
extract_sideband_loop(after_pkt, acc)
end
end
defp extract_pkt_payload(_rest, _payload_len, acc) do
if acc == <<>>, do: nil, else: acc
end
end