Git storage
How git data lives in Cloudflare R2 and is served without a native git binary.
This is the deepest, most performance-sensitive part of the codebase: how git
repository data is stored in Cloudflare R2, how reads and writes work with no
local disk at all, and how the Git smart HTTP protocol is served without a
native git binary.
Why R2, and why isomorphic-git
The deployment target is Vercel serverless functions — there is no persistent
disk, and no realistic way to bundle a native git binary into a function. So:
- isomorphic-git implements the git object model, protocol, and plumbing
entirely in JS, against a pluggable
fs-like interface. It doesn't care whether that interface is backed by a real filesystem or something else. - Cloudflare R2 (S3-compatible object storage) is that "something else" —
for reads and writes. Every git object, ref, and pack file lives in R2
under a canonical key scheme, and
git-fs.ts(built on the publishedgit-fs-s3package) speaks R2 on isomorphic-git's behalf.
There is no native git binary anywhere in the codebase, and no local
filesystem either: no /tmp working copy, no hydrate/sync step, no scratch
worktree. Server code uses only Web APIs (fetch, WebCrypto, TextEncoder,
CompressionStream) plus node:async_hooks for perf-log's request context.
Without R2 env vars (unit tests, a quick local run), git-fs.ts backs the same
stack with an in-memory object store instead — repositories then last as long
as the process; production refuses to start that way.
Storage key scheme
All git data for a repository lives at:
repos/{ownerKey}/{repoName}/git/{path-inside-the-bare-repo}e.g. repos/alice/my-repo/git/refs/heads/main,
repos/alice/my-repo/git/objects/pack/pack-abc123.pack.
apps/web/src/server/git-storage-naming.ts is the only place that constructs these
keys — never build an R2 key by hand elsewhere in the codebase. Its key
functions:
ownerKeyOf(repo)— the owner segment: the owner's username, which is required and immutable (see authentication.md).getRepoGitStorageRoot(ownerKey, repoName)/getRepoGitStoragePrefix— the gitdir and its key prefix. Both check every segment againstSTORAGE_NAME_REand throw rather than build a key from anything else: a segment becomes part of a gitFs path, whichgit-fs-s3normalizes (resolving..) before mapping onto a key, so an unchecked..would reach another repository's prefix. Repo names and usernames are restricted to the same pattern at input. See security.md.parseRepoGitStorageKey(key)— the inverse, for classifying keys.
The storage stack: git-fs.ts
git-fs.ts composes pushstack's R2-backed isomorphic-git fs from decorators
the published git-fs-s3 package provides, network-outward to gitdir-facing:
R2 store (git-fs-s3/fetch-s3, wired in apps/web/src/lib/r2.ts) → createRetryStore → perfR2 (perf-log.ts) → createCachedStore → createGitFsEvery git operation — clone, fetch, push, web-UI commits, merges, branch changes — goes through this one stack.
Gitdirs are the full storage roots (repos/{ownerKey}/{repoName}/git), so fs
paths map 1:1 onto R2 keys — the store is built with no key prefix. Each
layer's job:
- R2 store (
git-fs-s3/fetch-s3'screateFetchS3Store, configured from env byapps/web/src/lib/r2.ts) — the rawGetObject/PutObject/DeleteObject/HeadObject/ListObjectsV2(plus server-sideCopyObject, used by renames) calls against R2's S3 API, signed withaws4fetchand sent with plainfetch: no AWS SDK, nonode:httpsagent. Node's built-in fetch pools keep-alive connections itself. Each attempt has a timeout (AbortSignal.timeout: 30s for bodies, 10s for metadata calls); failures throwS3HttpErrorcarrying$metadata.httpStatusCode, which the retry layer classifies.git-fs.tsowns the app's single instance. createRetryStore— exponential backoff + jitter, plus a per-instance circuit breaker (5 consecutive non-404 failures within 30s opens it, failing fast instead of piling up retries against an R2 outage). Placed directly above the network store so the cache above it never stores a transient failure, and callers coalesced onto one request (see below) share a single retried attempt. The defaultisRetryable(the library'sisRetryableError: 5xx, 429, fetch'sTypeErrorfor a dropped connection,TimeoutError, Node socket codes) is used as is.createCachedStore— an in-process LRU read cache (GIT_CACHE_MAX_SIZE/GIT_CACHE_TTL-tunable, default 1GB / 1h), with:- Request coalescing (on by default) — if 100 concurrent object reads
all want the same not-yet-cached pack file, only one R2
GetObjectfires; the other 99 await the same in-flight promise. Coversget/head/listindependently, so e.g. several concurrent ref/branch lookups that each stat the gitdir root on a cold cache also collapse to one real round trip instead of one each. - Negative-result caching (
cacheMisses: true) — a loose-object probe on a packed repo is almost always a miss; caching "this key doesn't exist" avoids repeating the same doomed round trip. - Directory-listing caching (
cacheLists: true) —readdir/existence probes. A non-emptylimit: 1probe ("this directory exists") survives writes underneath it, since adding a key below a prefix can't make that prefix stop existing; empty probes and full listings are dropped on any write/delete that could affect them. - Ref-aware TTL (
git-fs.ts'srefAwareTtl, passed asttlForKey) — the cache's long default TTL is right for content-addressed object keys (a given key's bytes never change) but wrong for the mutable parts of a gitdir: a ref moves on every push, and theobjects//objects/pack/listings grow on every push, while the key naming either stays the same. Since this cache is in-process per server instance with no cross-instance invalidation, a warm instance that isn't the one handling a given push could otherwise keep serving a pre-push ref, or fail to discover a freshly pushed pack exists at all, for up to the full TTL.refAwareTtlgivesHEAD,refs/*, and the two listing paths a 5-second override instead — cheap (each is one small object or a bounded listing) and safe (everything downstream, keyed by the sha a fresh ref resolves to, still gets the full-length cache benefit once the structure itself is current). See performance.md.
- Request coalescing (on by default) — if 100 concurrent object reads
all want the same not-yet-cached pack file, only one R2
createGitFs(looseObjectHints: true) — the actualfsinterface isomorphic-git needs (readFile,writeFile,unlink,readdir,mkdir,rmdir,stat,lstat), plus:- Registered gitdirs (
registerGitdir, exposed here asregisterRepoGitdir(ownerKey, repoName)) — isomorphic-git stats the gitdir on every command and probespacked-refsandshallowon essentially every ref resolution or merge. Nothing in this codebase ever writes either file (refs are always loose, never packed; shallow clones are never created or advertised), and a repo's gitdir is known to exist once its DB row is found. Registering the gitdir withabsentFiles: ["packed-refs", "shallow"]answers all three with zero R2 calls — not even a cached miss, which wouldn't help a cold serverless invocation's empty cache (the common case on Vercel).absentFilesnames top-level gitdir files only, so a branch literally namedpacked-refs(refs/heads/packed-refs) is unaffected. - Loose-object hint (
detectLooseObjects, exposed here asdetectLooseObjectsHint(ownerKey, repoName)) — most repositories are fully packed, so every distinct commit a reachability walk touches probes a loose-object path (objects/xx/yyyy...) that's guaranteed to 404 — and since each commit has a different oid, the negative-result cache above never gets reused within one walk (60 commits = 60 guaranteed-failing R2 round trips, serially). One cheap boundedListObjectsV2call (relying on S3 key ordering: loose-object dirs00–ffsort beforeinfo/pack) tells us up front whether the repo has any loose objects at all; if not, every loose-objectreadFileshort-circuits to an immediateENOENTwith zero network calls. Flips back to "present" the instant a loose write actually lands, so it can never serve a false negative mid-push.git-http.tscalls it before upload-pack's reachability walk (beforeWalk).
- Registered gitdirs (
prefetchAllPacks(ownerKey, repoName) (git-fs.ts, wrapping GitFs.prefetchPacks)
is the other major lever: since walking
a commit chain is inherently sequential (you only learn the next oid to fetch
after reading the current commit), a deep git.log would otherwise pay one R2
round trip per commit whenever that commit isn't already in a downloaded
pack. Downloading every pack file in parallel before the sequential walk
starts turns "N sequential round trips" into "a few parallel downloads, then N
in-memory reads." Bounded by prefetchPacks's own maxPacks option (default
30, skipped past maxPacks * 2 pack files) so a repo with a long, fragmented
pack history doesn't pull down far more data than a shallow request actually
needs.
Ref names are qualified before resolving. For a bare name,
isomorphic-git's resolveRef tries <ref>, refs/<ref>, refs/tags/<ref>
before refs/heads/<ref>, 404ing each time. This app's ref model is
branch-only, so git-fs-s3's ops qualify bare names to refs/heads/<name>
(qualifyBranchRef) themselves; new code resolving a branch by name should
go through git-read.ts rather than isomorphic-git directly.
getCommitLog caches the deepest walk seen per head SHA and slices it for
shallower requests; commit history pages, file history and last-commit
lookups all reuse that one walk.
Writes: straight to R2, under the repo lock
Writes use the same gitFs as reads — there is no local copy to hydrate or
sync back. Each write path builds git objects and updates refs directly:
- Web-UI commits/deletes (
git-write.ts) —git-fs-s3/ops'scommitFilesToBare/deleteFileFromBare: blobs, then trees, then the commit, then the branch ref — no worktree, no index. - Merges (
git-write.ts'smergeBranches) —git-fs-s3/ops'sfastForwardMerge, elsegit-edge'sthreeWayMergeon the object graph. - Branch/HEAD changes (
git-write.ts) — ref writes. - Pushes and repacks (
git-http.ts, below) — the incoming pack and its index, CAS ref updates, pack consolidation.
The order makes every write safe to interrupt: objects land before any ref names them, so a killed function leaves at most unreferenced objects (which the next repack drops), never a ref pointing at something missing.
Locking
Every write runs inside withRepositoryLock(ownerKey, repoName, fn)
(git-repo-lock.ts) — a distributed lease-row lock backed by Postgres (the
repo_locks table), one row per {ownerKey}/{repoName} key, so concurrent
writes to the same repo serialize instead of racing even across different
serverless instances. This app runs on Vercel, where a lock scoped to one
process's memory gives zero protection — two concurrent pushes to the same
repo can land on two different, unrelated function invocations with no shared
state at all. Acquire is a single atomic INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at < now() RETURNING (works over Neon's stateless HTTP driver,
which can't hold a session-scoped pg_advisory_lock or a long-lived
transaction); release is a holder-scoped DELETE. The lease has a 60s TTL
that a live holder renews every 20s (a holder-scoped UPDATE ... RETURNING),
so a long push or repack never has its lock taken over mid-write; if a
holder's function is killed mid-critical-section (Vercel's execution limit),
it stops renewing and the repo self-recovers within one TTL instead of
staying locked forever. A renewal that finds the lease gone is logged as an
error.
It is not reentrant: a function already holding the lock must never call another function that tries to take it again for the same repo, or it times out after ~65s and throws.
repoFor(ownerKey, repoName) (git-repo.ts) is the handle every git call
takes: gitFs, the registered gitdir, and the shared per-repo
isomorphic-git cache.
Writes through gitFs keep this instance's caches current on their own (the
cached store updates or drops the keys it writes and deletes). After a push
or repack, afterRepositoryPush additionally drops the repo's ops.* result
caches (keyed per head sha, so this frees superseded entries rather than
fixing staleness) and isomorphic-git's per-repo pack-index cache
(a repack replaces pack files). Other warm instances pick up moved refs and new packs
through the 5s ref-aware TTL (see "Ref-aware TTL" above).
Renaming and deleting a repository
Every storage key in this layer is derived from the repository's current
name, read fresh from the DB — so renaming a repository has to move its
actual storage, not just update the DB row. renameRepositoryStorage
(git-repo.ts, via git-fs.ts's moveRepoStorage) server-side-copies
(CopyObject, no download/upload round trip) every object under the old
name's prefix to the new one, then deletes the old keys only after every copy
succeeds.
This function does not lock internally — repositories.ts's
updateRepository wraps both the storage move and the DB row update in a
single withRepositoryLock(ownerKey, repo.name, ...) call, so a concurrent
write for the old name can't observe a half-renamed state (old storage
partially copied, or DB already pointing at the new name while storage hasn't
moved yet). If the row update fails, the storage move is reverted.
deleteRepositoryStorage lists the repo's prefix and deletes every key.
deleteRepository deletes the DB row first, then runs it under the repo lock
(so an in-flight push can't land new objects after the listing); a storage
cleanup that fails afterwards only leaves unreachable objects. Those can't
leak into a later repository of the same name: createRepository inserts
its row first (the unique index decides a creation race before any storage
is touched), then, under the lock, clears whatever is still under the prefix
before initBareRepo — and rolls the row back if that fails.
No loose objects, ever
Every write stores its objects as packs: pushes (the incoming pack), web-UI
commits and merges (git-fs-s3's withPackedWrites — one pack per
operation, flushed before the ref moves), and repacks. That's what keeps the
loose-object hint correct across instances: a warm instance that once saw "no
loose objects" in a repo can't be told when another instance writes one, so
nothing ever does. Wrap any new code that
writes objects in withPackedWrites. Web commits and merges call
repackIfNeededUnlocked afterwards, inside their lock, so their one-pack-
per-operation doesn't accumulate past the repack threshold.
The Git smart HTTP protocol (git-http.ts)
This is what git clone https://…/api/git/owner/repo.git, git fetch and
git push talk to; apps/web/src/routes/api/git.$.ts calls serveGitRequest. The
protocol itself (pkt-line framing,
ref advertisement, upload-pack/receive-pack, the reachability walk, ref CAS,
pack consolidation) is git-fs-s3/http. serveGitRequest does the rest:
route parsing (parseGitUrl, which requires the /api/git/ prefix), the
streamed body cap, repo lookup, auth, the lock around pushes, perf hooks, and
post-push bookkeeping. Every service runs against gitFs.
-
info/refs —
git-fs-s3/http'shandleInfoRefs: lists all branches/tags/HEAD in parallel, resolves every ref's oid in parallel, and writes the pkt-line response. -
upload-pack (clone/fetch) —
handleUploadPackreads directly againstgitFs, lock-free. Its response wraps the packfile inside-band-64kframing (sideBandPackfile), whichhandleInfoRefsadvertises in the upload-pack capabilities line. Real nativegittolerates a raw, unframed packfile stream when side-band isn't negotiated, but not every client does — isomorphic-git's own HTTP client (GitSideBand.demux) always assumes side-band framing regardless of what was negotiated, and silently spins forever parsing raw packfile bytes as bogus pkt-line headers if it isn't there. Its pushes likewise always demux side-band, so receive-pack advertises it too.git-user-lifecycle.test.tsclones and pushes with isomorphic-git,pnpm smoke:gitwith native git.detectLooseObjectsHintandprefetchAllPacksrun in thebeforeWalkhook, so the reachability walk gets the same treatment as the web UI's history walks. -
receive-pack (push) —
parseReceivePackBody, thengit-fs-s3/http'sapplyReceivePackagainstgitFsunderwithRepositoryLock: write the incoming pack to R2 and index it (indexPackwrites the.idxbeside it), verify the pushed tips' object graph is complete, apply ref updates (compare-and-swap per ref, in parallel — a multi-ref push likegit push --allapplies all of them concurrently since each only touches its own ref file), thenrepackRepository(also fromgit-fs-s3/http), thenafterRepositoryPush. The whole push body is held in memory, capped byGIT_HTTP_MAX_BODY_BYTES— enforced while streaming (readGitRequestBody), not just fromContent-Length, which a chunked request doesn't send. The report is side-band-64k framed when the client asked for it (git and isomorphic-git both do); a pack that can't be indexed is reported asunpack index-pack failed. Branches must point at commits, and deleting the branchHEADnames is refused.The library validates every client-supplied
refNamewithisSafeFullRefNamebefore any ofgit.resolveRef/deleteRef/writeRefruns on it — an invalid name gets{ ok: false, reason: "invalid ref name" }in the response instead of reaching those calls. This isn't redundant withgit.writeRef's own internal validation: the top-levelgit.deleteRefandgit.resolveRefisomorphic-git exposes have no such check, and both resolve straight throughfs.rm/fs.read(join(gitdir, ref))— a"../"-ladenrefNamewould otherwise let a push with write access to any one repo read, corrupt, or delete another repo's ref/object files that happen to sit under the same shared storage root (see security.md's "Names are single safe segments" for the rule, and security-history.md for the incident that established it). The same validator (asisSafeBranchName, its bare-name variant, fromgit-ref-name.ts) guards every branch name accepted anywhere else in the app —files.ts/pull-requests.ts's input schemas, and defense-in-depth checks ingit-write.ts— sincegit.commit/git.merge/git.deleteBranchhave the same no-internal-validation gap and are reachable from ordinary web-UI actions (branch delete, PR merge), not just a raw git push.repackRepositoryconsolidates all local pack files into one, but only when the pack count is already at or aboveREPACK_PACK_COUNT_THRESHOLD(4) — below that, it's a no-op. Consolidating is O(total repo object count) (a full reachability traversal + independent re-verification of every object's SHA-1 +packObjects+indexPackover everything, not just what this push added), so doing it on every single push would make push latency grow with total repo size forever instead of with the size of the just-pushed delta; the threshold defers that cost until pack fragmentation actually matters.Its safety check for whether the old packs are actually safe to delete is traversal completeness (did every object the reachability walk visited actually get read successfully —
collectReachableOids'scompleteflag), not an object count: packs can overlap, so old packs' summed counts overstate the deduplicated total.repackRepositorywrites and indexes the consolidated pack, verifies it holds every reachable object, and only then deletes the superseded.pack/.idxfiles — all throughgitFs, so those deletes are the R2 deletes; there's never a window where R2 has neither the old nor the new pack.A lock-free clone/fetch can still race that delete: it may have listed
objects/pack/just before a concurrent push's repack removed one of the listed packs. Reads never take the write lock, socollectReachableOids(serves clone/fetch) tolerates it instead — it retries once, after a short delay, before marking an object genuinely missing, so the retry observes the current pack list rather than the mid-transition snapshot.
Auth for every git HTTP request goes through apps/web/src/server/git-auth.ts — see
authentication.md. Every git HTTP request (info/refs,
then upload-pack/receive-pack — at least two per operation) resolves the
target repository by owner+name; this goes through
repository-lookup.ts's cached findRepositoryByName, shared with the web UI's
repo pages — so the
second request of a push typically gets a free cache hit instead of hitting
Postgres again.
Environment variables that tune this layer
GIT_HTTP_MAX_BODY_BYTES=52428800 # optional, default 50MB — request body cap for push
GIT_CACHE_MAX_SIZE=1073741824 # optional, default 1GB — sizes git-fs.ts's raw R2 object cache directly; git-cache.ts's separate parsed-object cache (tree/commit results) budgets a quarter of this value
GIT_CACHE_TTL=3600 # optional, default 1 hour (seconds)GIT_CACHE_TTL sizes the R2 read cache's default TTL — right for
content-addressed object reads, but git-fs.ts's refAwareTtl overrides it
down to a fixed 5s for the mutable parts of a gitdir (HEAD, refs/*, and
the objects//objects/pack/ listings) via
git-fs-s3's ttlForKey option — not tunable by this env var.
See performance.md.