ref:main

git push rejected on any packed repo: thin-pack REF_DELTA resolver reads loose objects only #78

closed Opened by cole.christensen@gmail.com

Links

No links yet.

git push is rejected on any repository whose objects have been packed, unless the client passes --no-thin. Git sends a thin pack by default, and the resolver that supplies thin-pack REF_DELTA bases only reads loose objects.

Reported by another agent hitting it live against anvil.fangorn.io:fangorn/anvil.git on 2026-07-30, pushing a one-commit branch off origin/main:

error: remote unpack failed: ref_delta_base_not_found: unresolvable REF_DELTA at offset 1110
! [remote rejected] fix/374-ci-image-execution (ref_delta_base_not_found: ...)

Reproduced locally and root-caused below. Their diagnosis was correct in every detail.

Root cause

protocol/receive_pack.ex:542, build_external_resolver/1 — the resolver Pack.Reader calls for REF_DELTA bases that are not in the incoming pack:

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

Repo.storage_call(:get_object, ...) is Storage.Filesystem.get_object/3 (storage/filesystem.ex:40) — a single File.read of objects/ab/cdef…. It sees loose objects and nothing else.

A thin pack, which git push builds by default, cuts its deltas against objects the server advertised. Once those bases live in a packfile rather than loose — i.e. after any repack, the normal steady state of a real repository — every lookup misses, Reader.finalize_deferred/1 (pack/reader.ex:369) exhausts its passes, and the push is rejected outright.

The pack-aware read path already exists and is used everywhere else: ObjectResolver.read/2 checks packs first and falls back to loose. The comment at object_resolver.ex:187 is specifically about resolving REF_DELTA bases from within a pack. This one call site simply does not use it.

Reproduction

Server repo with zero loose objects, everything in one pack — exactly what git repack -ad or Maintenance leaves behind:

$ git count-objects -v
count: 0
in-pack: 3
packs: 1

Client produces a thin pack the way git push does — deltas cut against the base commit the server advertised:

$ git pack-objects --thin --stdout --revs --delta-base-offset # <new> ^<base>
292 bytes, 3 objects

Feeding that through ReceivePack.init/1feed/2 (command line + flush) → feed/2 (pack bytes) → flush/1:

base blob sha: d0eb82c1a6baac49ba2425297d50e9be3ed271f0
loose (Storage.get_object) : :error <- what build_external_resolver uses
pack-aware (ObjectResolver) : :ok <- what it should use
result: {:error, {:ref_delta_base_not_found, "unresolvable REF_DELTA at offset 194"}}
report to client:
"unpack ref_delta_base_not_found: unresolvable REF_DELTA at offset 194"
"ng refs/heads/main ref_delta_base_not_found: ..."
refs/heads/main still at 0c9dec83c628 — PUSH REJECTED

Same error as production, different offset only because the pack is smaller.

Repro script: bench/b7_thin_push.exs (constructs the packed repo, the thin pack, and drives the state machine directly — no server needed).

Fix

Point the resolver at ObjectResolver.read/2 and map the struct back to the {type, content} shape Pack.Reader expects — the same projection UploadPackV2.stream_object_contents/2 already does:

defp build_external_resolver(repo) do
fn sha ->
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = c} -> {:ok, {:commit, Object.encode_content_only(c)}}
{:ok, %Tree{} = t} -> {:ok, {:tree, Tree.encode_content(t)}}
{:ok, %Blob{content: content}} -> {:ok, {:blob, content}}
{:ok, %Tag{} = tag} -> {:ok, {:tag, Object.encode_content_only(tag)}}
{:error, _} = err -> err
end
end
end

This deletes decompress_and_parse_object/2, parse_raw_object/2 and classify_object_type/2 — hand-rolled object parsing that duplicates Object/ObjectResolver and was the only reason the loose-only path existed.

Verified against the identical repo state, identical thin pack, identical wire bytes:

result: :ok
report to client: "unpack ok" / "ok refs/heads/main"
refs/heads/main advanced to the new commit — PUSH SUCCEEDED

Integrity checked rather than assumed — git fsck --strict on the server repo is clean, the ref resolves to the expected SHA, and git cat-file -p <new>:big.txt is byte-identical to the client’s copy. mix test test/ex_git_objectstore/protocol/112 passed.

Patch held at bench/fix-thin-pack-resolver.diff; not pushed.

Why this shipped: the test mocks the broken seam

test/ex_git_objectstore/pack/reader_test.exs:263-283 is the only thin-pack coverage, and it injects a hand-written stub:

resolver = fn sha ->
if sha == base_sha, do: {:ok, {:blob, base_content}}, else: {:error, :not_found}
end
assert {:ok, [entry]} = Reader.parse(thin_pack, external_resolver: resolver)

That tests Pack.Reader’s handling of a resolver. It never constructs ReceivePack’s actual resolver, so build_external_resolver/1 has no coverage at all. No test in the suite pushes a thin pack into a repo whose objects are packed — the fixtures write loose objects and never repack, so the failing configuration cannot arise.

A regression test needs all three conditions together: objects in a pack, zero loose copies of the base, and a REF_DELTA whose base is one of them. bench/b7_thin_push.exs is that scenario and can be lifted into the suite.

Severity is rising, not static

This gets worse as the loose-object work lands. PR #228 (Maintenance, server-side repack) and #324 exist precisely to move repositories from loose to packed — and every repository they convert becomes one that rejects git push. The same is true of #77 F7 (keeping received packs instead of exploding them). The storage layout everything is moving toward is the layout that triggers this.

Anything that repacks a repo should be treated as blocked on this fix.

Related: same loose-vs-pack split noted for the S3 git migration, and ObjectResolver is pack-aware while Object.read/2 is loose-only — worth auditing other storage_call(:get_object, ...) call sites for the same assumption.

Workaround until fixed

git push --no-thin. Server-side there is none: the server cannot ask a client to have sent a different pack.