git-remote-object-store ships two storage engines that decide how
git data lands on the bucket:
bundle— one self-contained git bundle per ref per push.packchain— a newest-first manifest of incremental packs, with garbage collection and on-demand compaction.
Pick per-bucket with ?engine=<name> in the remote URL. If the flag
is absent on a new bucket, the engine defaults to bundle. The
choice is then persisted to a FORMAT key at the bucket prefix on
the first push and is read on every subsequent connection.
bundle |
packchain |
|
|---|---|---|
| Push wire cost | Full bundle of the ref every push | Incremental pack (only new objects) |
| Fetch wire cost (update) | Full bundle | New segment(s) only — walks chain until a known ancestor is found |
| Storage growth | Bounded per ref (old bundle deleted on each push) | Grows per push until compact rewrites the chain; old packs released by gc |
| Operational overhead | None | Schedule gc (weekly is fine); optionally compact |
| Direct single-file read | Not supported | Supported via packchain::read_blob |
Accelerated clone (bundle-uri) |
Not supported (bundle filename rotates per push) | Supported with ?bundle_uri=1 |
| Cross-push pack dedup | No — each bundle is self-contained | Yes when packs are byte-identical — they share a key under <prefix>/packs/<sha>.pack |
git bundle verify of stored artefacts |
Yes | Yes, on the baseline bundle only |
LFS, branch protection, per-ref push locking, branch deletion, and the
doctor management command work identically on both engines.
Pick bundle when:
- The repository is archival or rarely updated.
- You want zero operational surface — no GC, no compaction, no grace-window tuning.
- Clones are infrequent or the repo is small enough that a full bundle download per ref is cheap.
- You want every stored artefact to be inspectable with stock
git bundle verify/git bundle list-heads.
Pick packchain when:
- The repository is actively pushed to by a team and you care about push/fetch latency.
- You want incremental fetches — clones after the first one download only new packs.
- You want to read individual files at the tip without cloning
(
packchain::read_blob). - You can run a weekly
gcjob (cron, GitHub Actions, or similar). - You want
git cloneto fetch the baseline bundle in parallel with the helper-protocol negotiation (?bundle_uri=1).
On every push, the helper builds a git bundle v2 file containing the
ref and uploads it to <prefix>/refs/heads/<branch>/<sha>.bundle,
where <sha> is the tip OID. The previous bundle for the same ref is
deleted in the same per-ref lock window. Force-pushes follow the same
flow: a new bundle is uploaded and the loser is removed.
On-bucket layout:
| Key | Purpose |
|---|---|
<prefix>/FORMAT |
Engine marker (bundle) |
<prefix>/HEAD |
Default-branch pointer |
<prefix>/refs/heads/<branch>/<sha>.bundle |
Bundle for the tip commit |
<prefix>/refs/heads/<branch>/LOCK#.lock |
Per-ref push lock |
<prefix>/refs/heads/<branch>/PROTECTED# |
Branch-protection sentinel (optional) |
<prefix>/refs/heads/<branch>/repo.zip |
Source archive (when ?zip=1 is set) |
<prefix>/lfs/<oid> |
Git LFS objects (when LFS is in use) |
Fetches download the bundle for the requested ref and unbundle it
locally. There is no incremental fetch — every fetch transfers the
full bundle. This is the simplest model the project ships; if you do
not have a specific reason to pick packchain, this is the right
default.
A chain.json manifest at <prefix>/refs/heads/<branch>/chain.json
records a newest-first list of pack segments. The first push uploads
a baseline bundle plus a single-segment chain; each subsequent push
builds an incremental pack containing only the new objects, uploads
it under <prefix>/packs/<content-sha>.pack (plus its .idx), and
prepends a new segment to chain.json. Pack files are
content-addressed, so two pushes that produce byte-identical packs
deduplicate naturally — including across branches.
Fetches walk segments newest-to-oldest, stop at the first segment whose tip is already present locally, and download only the segments above that boundary (in parallel). A first-time clone walks all the way to the baseline bundle.
On-bucket layout:
| Key | Purpose |
|---|---|
<prefix>/FORMAT |
Engine marker (packchain) |
<prefix>/HEAD |
Default-branch pointer |
<prefix>/refs/heads/<branch>/chain.json |
Newest-first segment manifest |
<prefix>/refs/heads/<branch>/<full_at>.bundle |
Baseline bundle, named by the SHA that chain.full_at points at |
<prefix>/refs/heads/<branch>/path-index.json |
path → blob OID map at the tip (powers read_blob) |
<prefix>/refs/heads/<branch>/LOCK#.lock |
Per-ref push lock |
<prefix>/refs/heads/<branch>/PROTECTED# |
Branch-protection sentinel (optional) |
<prefix>/packs/<sha>.pack + .idx |
Content-addressed pack segments |
<prefix>/gc/tombstones-<run-id>-<timestamp>.json |
GC mark-phase records |
<prefix>/lfs/<oid> |
Git LFS objects (when LFS is in use) |
Force-pushes, branch deletions, and compactions detach packs from the
chain. git-remote-object-store gc removes them through a two-phase
mark-and-sweep with a grace window (default 24 hours; override with
--grace-hours or the GIT_REMOTE_OBJECT_STORE_GC_GRACE_HOURS env
var — see environment-variables.md). The
grace window protects in-flight readers — a clone that started before
the mark phase is allowed to finish even if gc decided the pack was
orphan.
A single weekly invocation is the standard schedule: each run sweeps the previous run's tombstones and writes new ones. See Getting started §9.1 for cron and GitHub Actions examples.
Long chains slow down fetches. git-remote-object-store compact
rewrites a chain into a single root segment in place. The heuristic
recommends compaction at roughly 20 segments or 100 MiB of cumulative
segment bytes per ref, whichever comes first; --force overrides the
heuristic. The compaction holds the per-ref lock for the duration of
the rewrite; after compaction, the orphaned segment packs become GC
candidates. See
Getting started §9.2 for
when to run it, the --with-gc shortcut, and lock-TTL guidance.
The library exposes packchain::read_blob(&remote, ref, path, &cache)
for fetching a single file at a ref's tip without cloning. The lookup
goes through path-index.json, then walks pack indices
newest-first. The caller-supplied PackIndexCache (a byte-bounded
LRU, 64 MiB by default) amortises index downloads across many
read_blob calls; on a bundle-engine remote the function returns
PackchainError::WrongEngine instead.
bundle-uri is a git protocol capability that lets the server tell
git clone "fetch this baseline pack directly from " — git
downloads it from object storage (or a CDN) in parallel with the
helper-protocol negotiation, skipping the chain walk. With
?bundle_uri=1 set, the helper advertises the baseline pack's URL
per ref; the creation token is the chain's full_at SHA, so a cached
baseline is reused across clones until the next force-push or
compact. See Getting started §10
for when to enable it, public-bucket vs private-bucket setup, and
the security trade-offs of emitting presigned URLs.
The FORMAT key is written once on first push and read-only after
that. There is no in-place migration:
- A bucket that already has
FORMAT=bundlecannot be converted topackchain(or vice versa) without manually creating a new bucket prefix and pushing into it. - Connecting with
?engine=packchainto aFORMAT=bundlebucket fails withBackendError::EngineMismatchbefore any data is written, and the same in reverse.
To migrate, create a new remote URL with the desired ?engine=,
push every branch into it, then update consumers.
- Default may surprise large-repo users. Active monorepos
typically want
packchain; the default ofbundleoptimises for zero ops, not throughput. Set?engine=packchainexplicitly on the first push if you know you want it. ?bundle_uri=1has no effect onbundle-engine remotes; there is no stable per-ref URL to advertise. The helper silently omits thebundle.<ref>.urilines.gcandcompactare effectively no-ops onbundle-engine remotes.bundlebuckets have nopacks/namespace and nochain.jsonfiles, so the mark phase finds nothing to tombstone andcompactfinds nothing to rewrite. Running them on a mixed-engine inventory is safe.- Engine choice is per-bucket-prefix, not per-ref. Different prefixes of the same bucket can hold different engines, but all refs under one prefix share one engine.