Skip to content

fix: accept thin packs in git receive-pack (HTTP + SSH)#47

Merged
mkappworks merged 10 commits into
mainfrom
bug/git-receive-thin-pack-fix
May 22, 2026
Merged

fix: accept thin packs in git receive-pack (HTTP + SSH)#47
mkappworks merged 10 commits into
mainfrom
bug/git-receive-thin-pack-fix

Conversation

@mkappworks
Copy link
Copy Markdown
Contributor

Summary

Native git push produces thin packs by default — packs whose objects are delta-encoded against base objects already on the server. Cloudzilla's pure-Go receive-pack rejected them: go-git's filesystem.Storage takes a fast path in packfile.UpdateObjectStorage that runs the pack parser without storage access, so REF_DELTAs with an external base can't be resolved. Pushes failed with reference delta not found and HTTP 500 (the workaround was per-clone pack.window=0, unacceptable for production).

This routes the receive-pack storer — on both the HTTP and SSH transports — through a new gittransport.WrapForReceive wrapper that hides the storer's PackfileWriter method via interface-embedding. That forces go-git onto its slower NewParserWithStorage path, which can resolve external delta bases. The "no git binary required" invariant is preserved.

Closes #

Changes

  • New internal/gittransport package — WrapForReceive (interface-embedding storer wrapper) and ByteCounter (atomic byte-counting io.ReadCloser for observability).
  • HTTP receive-pack (internal/handler/git_http.go) routes the storer through WrapForReceive and emits a structured git-http: receive-pack complete log line (pack_bytes, duration_ms).
  • SSH receive-pack (internal/ssh/server.go) — symmetric change; execGitService now threads owner/repo/pusher for the matching ssh: receive-pack complete log line.
  • New ## Thin packs section in docs/git-transport.md documenting the rationale, trade-off (loose objects), and observability surface.
  • go-billy/v5 promoted to a direct dependency in go.mod (the new package's unit test imports memfs directly).
  • Tests: a load-bearing unit invariant (WrapForReceive hides PackfileWriter), a transport smoke test, and ByteCounter tests (incl. concurrency under -race).

Notes for reviewers

  • The integration test is an honest transport smoke test, not a bug reproduction: go-git's own client never emits a thin pack with an external REF_DELTA base, so it can't trigger the server-side failure. The load-bearing regression guard is the TestWrapForReceive_HidesPackfileWriter unit invariant; end-to-end behavior needs manual verification with a native git client (still pending).
  • Loose-object GC is a documented, intentional follow-up (the slow path lands objects loose rather than packed).

Type of change

  • Bug fix (bug/...)
  • New feature (feat/...)
  • Technical / infrastructure (tech/...)
  • Documentation (docs/...)
  • Refactor / internal improvement

Checklist

  • make lint passes with no issues — ⚠️ make lint reports 49 issues, all pre-existing on main; golangci-lint --new-from-rev=main confirms 0 introduced by this PR (the repo has no .golangci.yml and CI doesn't run golangci-lint).
  • go test ./... passes
  • No .templ files changed — n/a
  • No new template directory — n/a
  • No new page — n/a
  • No setup-gated routes added — n/a
  • No schema changes — n/a
  • PR title follows Conventional Commits (fix:)
  • PR is focused on a single concern

mkappworks and others added 7 commits May 22, 2026 02:55
Routes go-git's receive-pack off the filesystem fast path onto
NewParserWithStorage so REF_DELTAs against existing server objects
can be resolved. See docs/superpowers/specs/2026-05-15-git-receive-
thin-pack-fix-design.md.
Drives a go-git client push against an in-process server whose storer is
wrapped by WrapForReceive, and verifies the push lands and links to
pre-existing history.

This is a transport smoke test, not a thin-pack bug reproduction: go-git's
client never emits an external REF_DELTA base, so it cannot exercise the
filesystem fast-path failure. Regression coverage for the bug lives in the
TestWrapForReceive_HidesPackfileWriter unit test plus manual native-git
verification.
Tracks pack size via an atomic counter. Used by both HTTP and SSH
receive-pack handlers in a follow-up commit.
Pushes from native git clients producing thin packs now succeed
(previously failed with 'reference delta not found' from go-git's
filesystem-storer fast path). Also emit a structured slog.Info line
with pack_bytes and duration_ms for slow-path visibility.
Symmetric change to the HTTP handler: wraps the server-side storer
and emits the same six-field observability log line
(owner, repo, pusher, commands, pack_bytes, duration_ms).
Captures why receive-pack routes the storer through
gittransport.WrapForReceive, the trade-off (loose objects vs.
correctness), and the observability surface.
internal/gittransport/storer_test.go imports go-git/go-billy/v5/memfs
directly; go.mod now reflects that (previously // indirect via go-git).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mkappworks mkappworks self-assigned this May 22, 2026
mkappworks and others added 3 commits May 22, 2026 16:56
Follow-up to the thin-pack fix, addressing a code/security/silent-failure
review:

- M-1: reproduce the thin-pack bug in a hermetic test — a hand-crafted
  REF_DELTA pack that fails without WrapForReceive and passes with it —
  replacing manual-only verification.
- M-2: cap pushed pack size via git.max_pack_bytes (default 2 GiB),
  enforced post-decompression on both HTTP and SSH so it bounds an
  oversized pack and a gzip bomb alike; over-limit pushes get HTTP 413
  / a clear SSH error. Add a 60s SSH IdleTimeout.
- M-3: add `cloudzilla gc` to prune loose objects unreachable from every
  ref and older than a grace period (internal/gitgc).
- L-1: log refs_ok/refs_failed so partially-rejected pushes are visible.
- L-2/L-3: document the deliberate SSH session Close suppression and
  tighten verbose comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- gitgc: refuse to prune a repo with zero ref roots — an empty root set
  made every loose object look unreachable and could empty the object DB
- gitgc: reject symlinked .git/objects dirs so the GC cannot be steered
  into deleting outside the repository; skip non-regular object entries
- gittransport: LimitedReadCloser returns (0, err) on the cap-crossing
  read — io.ReadFull cleared the error when the buffer was satisfied,
  letting an over-limit pack finish parsing as success
- cmd/cloudzilla: validate `gc --repo` owner/name (rejects ../ escape);
  report skipped/failed counts; skip unreadable owner dirs
- ssh: derive receive/upload-pack context from the session so a dropped
  client aborts ingestion
- trim non-WHY comments across the changed files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses the two Medium residuals from review:

- ssh: add git.ssh_max_session (default 2h, 0 disables) wired to
  ssh.Server.MaxTimeout — an absolute cap so a slow client trickling
  bytes cannot hold a connection past the idle timeout indefinitely.
- gittransport: add AppliedCommands; both transports now run branch
  protection, webhooks, and post-receive only for refs go-git actually
  applied. A per-ref failure surfaces in the report status, not as a
  ReceivePack error, so unfiltered side effects fired for rejected refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mkappworks mkappworks merged commit 52a6f21 into main May 22, 2026
3 checks passed
@mkappworks mkappworks deleted the bug/git-receive-thin-pack-fix branch May 22, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant