@@ -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.Protocol.ReceivePackThinPackTest do
@moduledoc """
Thin-pack pushes into a repository whose objects are **packed** (#78).
`git push` sends a thin pack by default: REF_DELTAs cut against objects the
server advertised, which the pack therefore omits. `ReceivePack` supplies
those bases through `build_external_resolver/1`.
The condition that broke it is all three of these at once — the base is in a
packfile, there is **no loose copy of it**, and an incoming REF_DELTA names
it. Existing coverage never had it: `reader_test.exs` injects a hand-written
resolver stub (so `build_external_resolver/1` itself is never exercised), and
every other fixture writes loose objects and never repacks, so the failing
layout could not arise. Every packed repo — the steady state after any
`git repack`, and what `Maintenance` deliberately produces — rejected `git
push` outright unless the client passed `--no-thin`.
These tests assert the no-loose-copy precondition explicitly. Without that
guard they pass vacuously against the loose-only resolver and prove nothing.
"""
use ExUnit.Case, async: true
import Bitwise
alias ExGitObjectstore.{Object, Ref, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack}
alias ExGitObjectstore.Test.RepoHelper
@zero String.duplicate("0", 40)
describe "REF_DELTA base that exists only in a packfile (#78)" do
setup do
repo = RepoHelper.memory_repo("thin-pack-#{:erlang.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
# Long enough that a delta against it is meaningfully smaller than the
# object, which is the case git actually produces.
base_content = String.duplicate("shared line of content\n", 40)
base_blob_sha = write_blob_to_pack(repo, base_content)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: base_blob_sha}])
tree_sha = write_object_to_pack(repo, :tree, Tree.encode_content(tree), Object.hash(tree))
base_commit = commit(tree_sha, [], "base\n")
base_commit_sha =
write_object_to_pack(
repo,
:commit,
Commit.encode_content(base_commit),
Object.hash(base_commit)
)
:ok = Ref.put(repo, "refs/heads/main", base_commit_sha, nil)
# The whole point of the fixture: these live in a packfile and nowhere
# else. If a loose copy existed, the loose-only resolver would find it
# and the test would prove nothing.
assert {:error, _} = Repo.storage_call(repo, :get_object, [base_blob_sha]),
"base blob must not have a loose copy, or this test is vacuous"
assert {:error, _} = Repo.storage_call(repo, :get_object, [base_commit_sha]),
"base commit must not have a loose copy, or this test is vacuous"
%{
repo: repo,
base_content: base_content,
base_blob_sha: base_blob_sha,
base_commit: base_commit,
base_commit_sha: base_commit_sha
}
end
@tag requirements: ["REQ-GIT-082"]
test "a push whose blob is deltified against a packed base is accepted", ctx do
%{repo: repo, base_content: base_content, base_blob_sha: base_blob_sha} = ctx
target_content = base_content <> "a line the client just added\n"
target_blob_sha = blob_sha(target_content)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: target_blob_sha}])
tree_sha = Object.hash(tree)
new_commit = commit(tree_sha, [ctx.base_commit_sha], "append a line\n")
new_commit_sha = Object.hash(new_commit)
# The blob rides as a REF_DELTA against the *packed* base; the tree and
# commit ride whole. That is the shape `git push` builds.
thin_pack =
build_pack([
ref_delta_entry(base_blob_sha, delta(base_content, target_content)),
full_entry(2, Tree.encode_content(tree)),
full_entry(1, Commit.encode_content(new_commit))
])
{result, data_lines} = push(repo, ctx.base_commit_sha, new_commit_sha, thin_pack)
assert result == :ok
assert "unpack ok" in data_lines
assert "ok refs/heads/main" in data_lines
assert {:ok, ^new_commit_sha} = Ref.get(repo, "refs/heads/main")
# The delta was applied to the right bytes: the reconstructed blob is
# byte-identical to what the client had, and is addressable by the SHA
# the client computed for it.
assert {:ok, %Blob{content: ^target_content}} = Object.read(repo, target_blob_sha)
end
@tag requirements: ["REQ-GIT-082"]
test "a REF_DELTA against a packed base whose own base is a commit", ctx do
%{repo: repo, base_commit: base_commit, base_commit_sha: base_commit_sha} = ctx
# Deltifying a commit against a commit is unusual but legal, and it
# exercises the resolver's non-blob projection.
base_commit_content = Commit.encode_content(base_commit)
target_commit = commit(base_commit.tree, [base_commit_sha], "second\n")
target_content = Commit.encode_content(target_commit)
target_sha = Object.hash(target_commit)
thin_pack =
build_pack([
ref_delta_entry(base_commit_sha, delta(base_commit_content, target_content))
])
{result, data_lines} = push(repo, base_commit_sha, target_sha, thin_pack)
assert result == :ok
assert "unpack ok" in data_lines
assert {:ok, ^target_sha} = Ref.get(repo, "refs/heads/main")
assert {:ok, %Commit{message: "second\n"}} = Object.read(repo, target_sha)
end
@tag requirements: ["REQ-GIT-082"]
test "a REF_DELTA naming a base the repo genuinely lacks is still rejected", ctx do
%{repo: repo, base_content: base_content, base_commit_sha: base_commit_sha} = ctx
absent = String.duplicate("ab", 20)
target_content = base_content <> "x\n"
thin_pack =
build_pack([ref_delta_entry(absent, delta(base_content, target_content))])
{result, data_lines} = push(repo, base_commit_sha, blob_sha(target_content), thin_pack)
# Fixing the packed-base lookup must not turn a genuinely unresolvable
# delta into a silent success.
assert {:error, {:ref_delta_base_not_found, _}} = result
refute "unpack ok" in data_lines
assert {:ok, ^base_commit_sha} = Ref.get(repo, "refs/heads/main")
end
end
describe "loose bases keep working" do
@tag requirements: ["REQ-GIT-082"]
test "a REF_DELTA against a loose base still resolves" do
repo = RepoHelper.memory_repo("thin-loose-#{:erlang.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
base_content = String.duplicate("loose base line\n", 30)
base_blob = Blob.from_content(base_content)
{:ok, base_blob_sha} = Object.write(repo, base_blob)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: base_blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
base_commit = commit(tree_sha, [], "base\n")
{:ok, base_commit_sha} = Object.write(repo, base_commit)
:ok = Ref.put(repo, "refs/heads/main", base_commit_sha, nil)
# Loose, by construction — the opposite precondition to the packed tests.
assert {:ok, _} = Repo.storage_call(repo, :get_object, [base_blob_sha])
target_content = base_content <> "appended\n"
target_blob_sha = blob_sha(target_content)
new_tree = Tree.new([%{mode: "100644", name: "f.txt", sha: target_blob_sha}])
new_tree_sha = Object.hash(new_tree)
new_commit = commit(new_tree_sha, [base_commit_sha], "append\n")
new_commit_sha = Object.hash(new_commit)
thin_pack =
build_pack([
ref_delta_entry(base_blob_sha, delta(base_content, target_content)),
full_entry(2, Tree.encode_content(new_tree)),
full_entry(1, Commit.encode_content(new_commit))
])
{result, data_lines} = push(repo, base_commit_sha, new_commit_sha, thin_pack)
assert result == :ok
assert "unpack ok" in data_lines
assert {:ok, %Blob{content: ^target_content}} = Object.read(repo, target_blob_sha)
end
end
describe "annotated tag as a REF_DELTA base" do
@tag requirements: ["REQ-GIT-082"]
test "resolves a packed tag base", %{} do
repo = RepoHelper.memory_repo("thin-tag-#{:erlang.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
blob_sha = write_blob_to_pack(repo, "tagged\n")
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
tree_sha = write_object_to_pack(repo, :tree, Tree.encode_content(tree), Object.hash(tree))
base_commit = commit(tree_sha, [], "base\n")
commit_sha =
write_object_to_pack(
repo,
:commit,
Commit.encode_content(base_commit),
Object.hash(base_commit)
)
base_tag = %Tag{
object: commit_sha,
type: "commit",
tag: "v1",
tagger: "Test <t@t.com> 1000000000 +0000",
message: String.duplicate("tag body line\n", 20)
}
base_tag_content = Tag.encode_content(base_tag)
base_tag_sha = write_object_to_pack(repo, :tag, base_tag_content, Object.hash(base_tag))
assert {:error, _} = Repo.storage_call(repo, :get_object, [base_tag_sha])
target_tag = %{base_tag | tag: "v2"}
target_content = Tag.encode_content(target_tag)
target_sha = Object.hash(target_tag)
thin_pack =
build_pack([ref_delta_entry(base_tag_sha, delta(base_tag_content, target_content))])
{result, data_lines} = push(repo, @zero, target_sha, thin_pack, "refs/tags/v2")
assert result == :ok
assert "unpack ok" in data_lines
assert {:ok, %Tag{tag: "v2"}} = Object.read(repo, target_sha)
end
end
# ── Driving the protocol ────────────────────────────────────────────────
defp push(repo, old_sha, new_sha, pack_data, ref \\ "refs/heads/main") do
{_advert, state} = ReceivePack.init(repo)
commands = PktLine.encode("#{old_sha} #{new_sha} #{ref}") <> PktLine.flush()
{feed_resp, state} = ReceivePack.feed(state, commands <> pack_data)
{flush_resp, state} = ReceivePack.flush(state)
{:ok, packets, _} = PktLine.decode(feed_resp <> flush_resp)
{state.result, for({:data, d} <- packets, do: d)}
end
# ── Fixture writers: pack-only, never loose ─────────────────────────────
defp write_blob_to_pack(repo, content) do
blob = Blob.from_content(content)
write_object_to_pack(repo, :blob, Object.encode_content_only(blob), Object.hash(blob))
end
defp write_object_to_pack(repo, type, raw_content, sha) do
{pack_data, idx_data, pack_sha} = Writer.generate_with_index([{type, raw_content, sha}])
:ok = Repo.storage_call(repo, :put_pack, [pack_sha, pack_data, idx_data])
sha
end
defp commit(tree_sha, parents, message) do
%Commit{
tree: tree_sha,
parents: parents,
author: "Test <t@t.com> 1000000000 +0000",
committer: "Test <t@t.com> 1000000000 +0000",
message: message
}
end
defp blob_sha(content) do
:crypto.hash(:sha, "blob #{byte_size(content)}\0" <> content) |> Base.encode16(case: :lower)
end
# ── Thin-pack construction ──────────────────────────────────────────────
#
# Hand-built rather than produced by `Pack.Writer`, which emits only whole
# objects. A REF_DELTA entry is a type-7 header, the 20-byte base SHA, then
# the zlib-compressed delta.
defp build_pack(entries) do
header = <<"PACK", 2::unsigned-big-32, length(entries)::unsigned-big-32>>
body = IO.iodata_to_binary([header, entries])
<<body::binary, :crypto.hash(:sha, body)::binary>>
end
defp full_entry(type_num, content) do
[pack_object_header(type_num, byte_size(content)), zlib_compress(content)]
end
defp ref_delta_entry(base_sha, delta_data) do
{:ok, base_sha_bin} = Base.decode16(base_sha, case: :lower)
[pack_object_header(7, byte_size(delta_data)), base_sha_bin, zlib_compress(delta_data)]
end
defp pack_object_header(type_num, size) do
first = bor(bsl(type_num, 4), band(size, 0x0F))
rest = bsr(size, 4)
if rest == 0, do: <<first>>, else: <<bor(first, 0x80), varint(rest)::binary>>
end
defp varint(value) do
byte = band(value, 0x7F)
rest = bsr(value, 7)
if rest == 0, do: <<byte>>, else: <<bor(byte, 0x80), varint(rest)::binary>>
end
# A minimal but spec-valid delta: copy the common prefix from the base, then
# insert the remainder of the target literally.
defp delta(base, target) do
prefix = common_prefix_length(base, target, 0)
suffix = binary_part(target, prefix, byte_size(target) - prefix)
size_varint(byte_size(base)) <>
size_varint(byte_size(target)) <>
copy_instruction(prefix) <>
insert_instructions(suffix)
end
defp common_prefix_length(<<c, a::binary>>, <<c, b::binary>>, n),
do: common_prefix_length(a, b, n + 1)
defp common_prefix_length(_, _, n), do: n
defp size_varint(n) when n < 128, do: <<n>>
defp size_varint(n), do: <<bor(band(n, 0x7F), 0x80)>> <> size_varint(bsr(n, 7))
defp copy_instruction(0), do: <<>>
defp copy_instruction(size) do
{bytes, mask} =
Enum.reduce([{0, 0x10}, {1, 0x20}, {2, 0x40}], {[], 0}, fn {idx, bit}, {acc, m} ->
byte = band(bsr(size, idx * 8), 0xFF)
if byte != 0, do: {acc ++ [<<byte>>], bor(m, bit)}, else: {acc, m}
end)
<<bor(0x80, mask)>> <> IO.iodata_to_binary(bytes)
end
defp insert_instructions(<<>>), do: <<>>
defp insert_instructions(data) when byte_size(data) <= 127 do
<<byte_size(data)>> <> data
end
defp insert_instructions(data) do
<<chunk::binary-size(127), rest::binary>> = data
<<127>> <> chunk <> insert_instructions(rest)
end
defp zlib_compress(data) do
z = :zlib.open()
try do
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
IO.iodata_to_binary(compressed)
after
:zlib.close(z)
end
end
end