ref:850623a61cc6537d8723e7f906590ffd87f33c84

fix(receive-pack): resolve thin-pack REF_DELTA bases from packs, not just loose

`git push` sends a thin pack by default: its REF_DELTAs are cut against objects the server advertised, and therefore omitted from the pack. `ReceivePack` supplies those bases through `build_external_resolver/1`, which went straight to `Repo.storage_call(:get_object, ...)` — a single `File.read` of `objects/ab/cdef…`. That sees loose objects and nothing else, so once the bases lived in a packfile every lookup missed, `Reader.finalize_deferred/1` exhausted its passes, and the push was rejected outright: error: remote unpack failed: ref_delta_base_not_found: unresolvable REF_DELTA at offset 1110 ! [remote rejected] main -> main Packed is the steady state of any real repository, and it is what `Maintenance.repack/1` deliberately produces — so this blocks #228. The resolver now goes through `ObjectResolver`, the pack-first read every other caller already uses. **Raw bytes rather than parse-and-re-encode.** The obvious projection is `ObjectResolver.read/2` followed by `Object.encode_content_only/1`, and it does work: 2639 objects — every object in this repository plus hand-built gpgsig, mergetag, `encoding`, empty-message and no-trailing-newline commits, submodule/symlink trees, and signed tags — round-trip byte-exactly through parse/encode. But nothing downstream would catch it if that ever stopped being true. `Pack.Reader` computes a resolved entry's SHA *from the delta result*, and `store_single_entry/2` stores it under that SHA; there is no expected SHA to check the reconstruction against. A base that was not byte-identical would not fail loudly — it would write a corrupted object under a self-consistent but wrong SHA. So `read_raw/2` is added to `ObjectResolver` and `Object`, returning `{type, content}` straight from storage. It shares the pack-first lookup with `read/2` (which now projects the same raw result), keeps the size cap and SHA verification on both paths, and skips a parse plus an encode per resolved base on the push path. This also deletes `decompress_and_parse_object/2`, `parse_raw_object/2` and `classify_object_type/2` — hand-rolled object parsing duplicating `Object`, and the reason the loose-only path existed. The deleted version called bare `:zlib.uncompress/1` with no size cap, so the new path additionally bounds decompression the way every other read does. Tests, each failing before the fix with the production error: - `receive_pack_thin_pack_test.exs` — thin pushes into a pack-only repo, with blob, commit and annotated-tag bases. The fixture asserts the base has **no loose copy**; without that guard the tests pass vacuously against the loose-only resolver. Two controls hold the boundaries: a loose base still resolves, and a base the repo genuinely lacks is still rejected. - `receive_pack_git_client_test.exs` — a real `git push` subprocess into a repo put through `Maintenance.repack/1`, i.e. the exact production scenario. This is the one that reproduced `ref_delta_base_not_found: unresolvable REF_DELTA at offset 196`. - `object_resolver_test.exs` — `read_raw/2` agrees with `read/2` byte for byte across types, falls back to loose, and enforces the size cap. `mix test` 1054 passed, `mix dialyzer` 0 errors, format and `--warnings-as-errors` clean. Closes #78 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: 850623a61cc6537d8723e7f906590ffd87f33c84
Author: t <t@t.com>
Date: 2026-07-30 22:42
Parents: 783dab7
6 files changed +629 -43
Type
lib/ex_git_objectstore/object.ex +55 −1
@@ -129,6 +129,35 @@
end
@doc """
Read a loose object's type and raw content without decoding it into a struct.
Same storage read, size cap, and SHA verification as `read/2` — it simply
stops before `decode_typed/2`. Callers that want the object's *bytes*
(delta bases, pack writers) would otherwise parse a struct and immediately
re-encode it, which is both wasted work and a correctness dependency on the
encoder being a byte-exact inverse of the parser.
"""
@spec read_raw(Repo.t(), String.t()) :: {:ok, {atom(), binary()}} | {:error, term()}
def read_raw(%Repo{} = repo, sha) do
:telemetry.span(
[:ex_git_objectstore, :object, :read],
%{sha: sha, repo_id: repo.id},
fn ->
result =
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, data} ->
decompress_and_verify_raw(data, sha, repo.max_object_size)
{:error, _} = err ->
err
end
{result, %{sha: sha, repo_id: repo.id}}
end
)
end
@doc """
Write an object to storage, returning its SHA.
"""
@spec write(Repo.t(), t()) :: {:ok, String.t()} | {:error, term()}
@@ -175,14 +204,39 @@
end
defp verify_sha_and_decode(raw, sha) do
case verify_sha(raw, sha) do
:ok -> decode_raw(raw)
{:error, _} = err -> err
end
end
defp decompress_and_verify_raw(data, sha, max_size) do
with {:ok, raw} <- safe_decompress(data, max_size),
:ok <- verify_sha(raw, sha),
{:ok, type_str, size, content} <- parse_header(raw) do
if byte_size(content) != size do
{:error, {:size_mismatch, expected: size, actual: byte_size(content)}}
else
type_atom(type_str, content)
end
end
end
defp verify_sha(raw, sha) do
actual_sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
if actual_sha != sha do
{:error, {:sha_mismatch, expected: sha, actual: actual_sha}}
else
decode_raw(raw)
:ok
end
end
defp type_atom("blob", content), do: {:ok, {:blob, content}}
defp type_atom("tree", content), do: {:ok, {:tree, content}}
defp type_atom("commit", content), do: {:ok, {:commit, content}}
defp type_atom("tag", content), do: {:ok, {:tag, content}}
defp type_atom(type, _content), do: {:error, {:unknown_type, type}}
defp encode_content(%Blob{content: content}), do: {"blob", content}
defp encode_content(%Tree{} = tree), do: {"tree", Tree.encode_content(tree)}
lib/ex_git_objectstore/object_resolver.ex +31 −5
@@ -88,11 +88,37 @@
@spec read(Repo.t(), String.t()) :: {:ok, Object.t()} | {:error, term()}
def read(%Repo{} = repo, sha) do
case read_from_packs(repo, sha) do
{:ok, {type, data}} ->
wrap_object(type, data)
{:error, :not_found} ->
Object.read(repo, sha)
{:error, _} = err ->
err
end
end
@doc """
Read an object's type and raw content, packs first, without decoding it
into a struct.
Same lookup as `read/2`; it stops one step earlier. Callers that want the
object's *bytes* — thin-pack REF_DELTA bases above all — would otherwise
decode a struct only to re-encode it, which is wasted work on a push-path
hot loop and makes correctness depend on the encoder being a byte-exact
inverse of the parser. Delta application has no SHA to check the
reconstruction against, so an inexact round-trip would not fail loudly: it
would store a corrupted object under a self-consistent but wrong SHA.
"""
@spec read_raw(Repo.t(), String.t()) :: {:ok, {atom(), binary()}} | {:error, term()}
def read_raw(%Repo{} = repo, sha) do
case read_from_packs(repo, sha) do
{:ok, _} = result ->
result
{:error, :not_found} ->
Object.read(repo, sha)
Object.read_raw(repo, sha)
{:error, _} = err ->
err
@@ -166,7 +192,7 @@
fetch = pack_fetch_fn(repo, pack_sha)
case Reader.read_object_ranged(fetch, offset, ranged_resolver(raw_idx, fetch)) do
{:ok, {type, data}} -> size_checked(repo, type, data)
{:ok, {type, data}} -> size_checked_wrap(repo, type, data)
{:error, _} = err -> err
end
end
@@ -216,7 +242,7 @@
fetch = in_memory_fetch(pack_data)
case Reader.read_object_ranged(fetch, offset, ranged_resolver(raw_idx, fetch)) do
{:ok, {type, data}} -> size_checked_wrap(repo, type, data)
{:ok, {type, data}} -> size_checked(repo, type, data)
{:error, _} = err -> err
end
end
@@ -230,12 +256,12 @@
fn offset, length -> {:ok, binary_part(pack_data, offset, min(length, size - offset))} end
end
defp size_checked_wrap(repo, type, data) do
defp size_checked(repo, type, data) do
if byte_size(data) > repo.max_object_size do
{:error,
{:object_too_large,
"decompressed size #{byte_size(data)} exceeds limit of #{repo.max_object_size} bytes"}}
else
wrap_object(type, data)
{:ok, {type, data}}
end
end
lib/ex_git_objectstore/protocol/receive_pack.ex +12 −36
@@ -539,43 +539,19 @@
# Build a function that resolves object SHAs from the existing repo.
# Returns {type_atom, raw_content} matching the format Pack.Reader expects.
#
# A thin pack — what `git push` sends by default — omits the objects its
# REF_DELTAs are cut against, because the server advertised them. Those
# bases live wherever the repo happens to keep them, and after any repack
# that is a packfile rather than a loose file. This used to go straight to
# `Repo.storage_call(:get_object, ...)`, which reads loose objects and
# nothing else, so on a packed repo every base lookup missed and the push
# was rejected outright with `ref_delta_base_not_found` (#78).
#
# `ObjectResolver.read_raw/2` is the same read every other caller uses:
# packs first, loose on miss, size-capped and SHA-verified either way.
defp build_external_resolver(repo) do
fn sha ->
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, compressed} ->
decompress_and_parse_object(compressed, sha)
{:error, _} = err ->
err
end
end
end
defp decompress_and_parse_object(compressed, sha) do
raw = :zlib.uncompress(compressed)
parse_raw_object(raw, sha)
rescue
_ -> {:error, {:decompress_failed, sha}}
end
defp parse_raw_object(raw, sha) do
case :binary.split(raw, <<0>>) do
[header, content] ->
classify_object_type(header, content)
_ ->
{:error, {:invalid_object_format, sha}}
end
end
defp classify_object_type(header, content) do
case String.split(header, " ", parts: 2) do
["commit", _] -> {:ok, {:commit, content}}
["tree", _] -> {:ok, {:tree, content}}
["blob", _] -> {:ok, {:blob, content}}
["tag", _] -> {:ok, {:tag, content}}
[other, _] -> {:error, {:unknown_object_type, other}}
end
fn sha -> ObjectResolver.read_raw(repo, sha) end
end
defp process_ref_updates(state) do
test/ex_git_objectstore/integration/receive_pack_git_client_test.exs +68 −1
@@ -27,7 +27,7 @@
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Ref
alias ExGitObjectstore.{ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Test.{FailingStorage, GitDaemon, RepoHelper}
@moduletag :integration
@@ -93,6 +93,73 @@
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, ^client_sha} = Ref.get(repo, "refs/heads/main")
after
stop.()
end
end
end
# The condition #78 was about, reached the way production reaches it: the
# server repo is repacked (what `Maintenance.repack/1` does, and what PR #228
# runs after a push), then a real `git push` sends its default thin pack with
# REF_DELTAs cut against objects that now exist only inside a packfile.
#
# Before the fix this failed with the client-visible
# `remote unpack failed: ref_delta_base_not_found: unresolvable REF_DELTA`
# which is the error five agents hit against anvil.fangorn.io on 2026-07-30.
describe "push into a repacked repository (#78)" do
@tag :tmp_dir
@tag requirements: ["REQ-GIT-082"]
test "real git push with a thin pack whose bases are packed", %{tmp_dir: tmp_dir} do
# Enough content that git chooses to deltify rather than send whole blobs.
body = String.duplicate("a line that will be deltified\n", 200)
repo = make_linear_repo("packed-thin", [{"a.txt", body}])
{:ok, stats} = ExGitObjectstore.Maintenance.repack(repo)
assert stats.packed > 0, "fixture did not pack anything"
# The precondition that makes this test meaningful. If any loose copy
# survived, the old loose-only resolver would have found it.
assert {:ok, []} = Repo.storage_call(repo, :list_objects, []),
"repo must have zero loose objects, or this test is vacuous"
{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
# A small edit to a large file is exactly what git sends as a REF_DELTA
# against the blob the server advertised.
File.write!(Path.join(client, "a.txt"), body <> "one appended line\n")
GitDaemon.git!(client, ["add", "a.txt"])
GitDaemon.git!(client, ["commit", "-m", "append"])
{port, stop} = GitDaemon.start_receive_pack(repo)
try do
{out, code} =
GitDaemon.git_at(client, [
"push",
"git://127.0.0.1:#{port}/repo",
"main:refs/heads/main"
])
assert code == 0, "push into a repacked repo failed:\n#{out}"
refute out =~ "ref_delta_base_not_found"
client_sha = GitDaemon.git!(client, ["rev-parse", "HEAD"])
assert {:ok, ^client_sha} = Ref.get(repo, "refs/heads/main")
# The pushed content survived the delta round-trip intact.
{:ok, %Commit{tree: tree_sha}} = ObjectResolver.read(repo, client_sha)
{:ok, %Tree{entries: [%{sha: blob_sha}]}} = ObjectResolver.read(repo, tree_sha)
assert {:ok, %Blob{content: content}} = ObjectResolver.read(repo, blob_sha)
assert content == body <> "one appended line\n"
after
stop.()
end
test/ex_git_objectstore/object_resolver_test.exs +72 −0
@@ -426,6 +426,71 @@
end
end
# `read_raw/2` exists so delta-base callers do not decode a struct only to
# re-encode it. Nothing downstream verifies a reconstructed delta result
# against an expected SHA — `Pack.Reader` computes the stored SHA *from* the
# result — so a base that is not byte-identical to what was stored would be
# written out as a corrupted object under a self-consistent but wrong SHA
# rather than failing loudly.
describe "read_raw/2" do
@tag requirements: ["REQ-GIT-083"]
test "returns the stored bytes for a packed object, not a re-encoding" do
repo = RepoHelper.memory_repo()
content = String.duplicate("packed payload\n", 25)
sha = write_blob_to_pack(repo, content)
assert {:ok, {:blob, ^content}} = ObjectResolver.read_raw(repo, sha)
end
@tag requirements: ["REQ-GIT-083"]
test "falls back to loose objects" do
repo = RepoHelper.memory_repo()
blob = Blob.from_content("loose payload\n")
{:ok, sha} = Object.write(repo, blob)
assert {:ok, {:blob, "loose payload\n"}} = ObjectResolver.read_raw(repo, sha)
end
@tag requirements: ["REQ-GIT-083"]
test "agrees with read/2 byte for byte across every object type" do
repo = RepoHelper.memory_repo()
blob_sha = write_blob_to_pack(repo, String.duplicate("body\n", 40))
tree_sha =
write_tree_to_pack(repo, [%{mode: "100644", name: "f.txt", sha: blob_sha}])
{commit_sha, _commit} = write_commit_to_pack(repo, tree_sha, "message body\n")
for sha <- [blob_sha, tree_sha, commit_sha] do
assert {:ok, {type, raw}} = ObjectResolver.read_raw(repo, sha)
assert {:ok, decoded} = ObjectResolver.read(repo, sha)
assert Object.encode_content_only(decoded) == raw
# The raw bytes are what the SHA is taken over — putting the header
# back around them must reproduce the object's own identity.
assert object_sha(type, raw) == sha
end
end
@tag requirements: ["REQ-GIT-083"]
test "reports not_found for an absent sha" do
repo = RepoHelper.memory_repo()
assert {:error, _} = ObjectResolver.read_raw(repo, String.duplicate("ab", 20))
end
@tag requirements: ["REQ-GIT-083"]
test "enforces the repo's max object size on the pack path" do
repo = RepoHelper.memory_repo()
sha = write_blob_to_pack(repo, String.duplicate("x", 5_000))
tiny = %{repo | max_object_size: 100}
assert {:error, {:object_too_large, _}} = ObjectResolver.read_raw(tiny, sha)
end
end
describe "REF_DELTA resolution with real git packs" do
test "reads objects from git gc'd packs with deltas" do
# Create a repo with similar files to trigger delta compression
@@ -482,6 +547,13 @@
end
# Helper for running git commands in tests
defp object_sha(type, content) do
type
|> Object.encode_raw_from_type(content)
|> then(&:crypto.hash(:sha, &1))
|> Base.encode16(case: :lower)
end
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}")
test/ex_git_objectstore/protocol/receive_pack_thin_pack_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.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