Repository navigation
Conversation
Named upstream.git hosts are served read-only over Git's smart HTTP
protocol at /git/{name}/{repo}, so git clone and git fetch work against
the proxy through an insteadOf rewrite. Each repository is a bare clone
at git.cache_dir/{name}-{hash of the upstream URL}/{repo}.git, served
by git upload-pack.
Every request for refs checks the upstream's branches, tags and HEAD
with a protocol v2 ls-refs limited to those prefixes, and fetches only
when they changed, atomically and with --prune. Concurrent requests
share one refresh. Only branches and tags are cached, and HEAD follows
the upstream's default branch.
A commit a client asks for by ID that the cache lacks, such as a pull
request head, is fetched and kept under a hidden ref until a branch or
tag includes it or for 7 days. One the upstream cannot provide is not
asked for again for a minute, or an hour when it is larger than
git.max_repo_size leaves. This is off for upstreams upstream.auth has
credentials for, since GitHub serves commits from any repository in a
fork network.
git never reaches an upstream itself. Its fetches go through a loopback
relay that forwards with the proxy's upstream client, so address
checks, upstream.auth and the access log apply, and git never sees a
credential. Redirects are not followed, and git runs with an
environment of its own.
The cached copy is served while the upstream fails or is slower than
git.refresh_wait, until git.stale_max_age has passed since the last
successful check. Refusals and missing repositories are never answered
from the cache.
git.max_repo_size cuts a transfer off as it streams, and every fetched
pack is kept as a pack, so a cut-off leaves nothing behind. A
repository that can no longer be refreshed within the limit is cloned
again with only its branches and tags. git.max_size evicts the least
recently used repositories, never one in use. At startup only the
directory of a repointed upstream's old URL is removed, and Close waits
for refreshes in progress.
Hosts with upstream.auth credentials need a repos allowlist, and a
repository outside it answers 404.
Refs git-pkgs#403
montehurd
force-pushed
the
git-http-cache
branch
from
October 15, 2026 11:21
85a5900 to
e515f37
Compare
Owner
Author
|
Superseded by git-pkgs#492. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Serve Git repositories read-only at
/git/{name}/{repo}from a local cache of bare repositories, the first part of git-pkgs#403.Freshness
Every
info/refsrequest asks the upstream for its branches, tags and HEAD (protocol v2ls-refswithref-prefix, so Gerrit filters outrefs/changes/*; a v0 advertisement is filtered here) and fetches only when they differ from the cache. That is one GET and one POST upstream per clone, shared by requests that arrive while it runs, and it gives up after two minutes so a stalled upstream cannot hold a refresh. Fetches use--atomic, so a client served while one runs sees all refs old or all new, andhttp.lowSpeedTimeabandons a transfer that receives nothing for 10 minutes. A new repository is cloned intocache_dir/.tmpand renamed into place. HEAD follows the upstream's default branch.If the upstream fails or is slower than
refresh_wait, a copy checked withinstale_max_ageis served while the refresh carries on. 401, 403, 404 and redirects are never answered from the cache, as discussed in git-pkgs#403. A repository outsidereposanswers 404, like a missing one.A commit a client asks for by ID that is on no cached branch or tag, such as the pull request head
actions/checkoutfetches, is fetched from the upstream into a hiddenrefs/proxy/wanted/{time}/{id}ref (uploadpack.hideRefs), so it is neither advertised nor garbage collected. The ref goes once a cached branch or tag includes the commit, or after 7 days. Concurrent requests for it share one fetch, and a commit the upstream could not provide, or could not be reached for, is not asked for again for a minute, so a client retrying a bad ID cannot hold the repository's fetch lock. When up to 4 commits are asked for together and the fetch fails, each is fetched on its own, so a good one is not refused with a bad one: at most 5 fetches under the repository's lock. Larger batches, and any batch the upstream never answered, as in an outage, are refused for the minute without being retried one by one. A stalled upstream costs each of those fetches up to the 10 minutelowSpeedTime. Fetching by ID is off for upstreams thatupstream.authhas credentials for (see below).Cached repositories set
uploadpack.allowAnySHA1InWant, so any commit the cache holds is served by ID, including one a force-push left behind, as the upstream would. A commit present in the cache is served without a reachability check, which is the trade. One race comes with it: a commit on no ref can be pruned by an auto gc inside a concurrent fetch while a client asks for it, and that client's fetch fails and retries.Spawned git and the upstream protections
gitnever reaches the network. Each fetch gets a route on a loopback relay inside the proxy (random port, random per-fetch ID mapped to one upstream repository URL, onlyinfo/refs?service=git-upload-packandPOST git-upload-pack), and the relay forwards with the same upstream client as every other request. So:allow_loopback,allow_private_hosts) apply to every connection, including the first;upstream.authis applied by URL scope, and the credential is never passed togit;git;The
gitprocesses get an environment of their own, with no system or global config, credential helpers, proxy variables orGIT_*from the proxy,GIT_ALLOW_PROTOCOL=httpandhttp.followRedirects=false. Tests check that a client refusing loopback addresses keepsgitfrom reaching a loopback upstream, both for the ref check and forgit's own fetch, and that a redirect target sees no request.An upstream that
upstream.authhas credentials for must listrepos, since everyone who can reach the proxy can read what it serves; the docs say so.Serving
internal/handler/git.goparses and validates the request, applies thereposallowlist and maps errors to statuses;internal/gitcachekeeps the repositories. Clients are served bygit upload-pack --stateless-rpcrun directly, asgit http-backendwould run it, rather than through CGI. Alpine'sgitpackage ships withouthttp-backend, and running upload-pack directly gives context cancellation when a client goes away, plain handling of gzip request bodies, and control over its environment. It needsgit2.31 or newer, forGIT_CONFIG_COUNTandfetch --atomic.git-upload-packrequests run in full duplex, since upload-pack writes its response while its stdin is still attached to the request body, and an HTTP/1 server otherwise discards and closes the rest of the body once the response is flushed. Pushing and the dumb protocol are refused. The server's 5 minute write timeout and 30 second read timeout are extended for these requests: a cold clone of a large repository keeps the client waiting before the first byte, and the rest of a request body is read only after any fetch it needs.Disk
Repositories live at
{cache_dir}/{name}-{URL hash}/{repo}.git, so an operator can see what is cached before the UI lists it, and repointing an upstream starts a fresh cache rather than serving the old host's repositories. At startup only the configured upstreams' directories are loaded, and those of their old URLs are removed and logged. Nothing else incache_diris touched, so pointing it at a directory that also holds the artifact cache or the database is safe; directories of upstreams no longer configured are logged for the operator to remove. Path segments are validated (no empty,.or..segments, no control characters or backslashes, and only the last segment may end in.git, so no repository's directory sits inside another's).max_repo_size: the relay counts the bytes of each fetch and cuts it off at the remaining budget, so an oversized repository is not downloaded in full first. Every fetched pack is kept as a pack (transfer.unpackLimit=1): index-pack only moves a complete pack into place, where a small pack would otherwise be unpacked object by object and a cut-off would leave, say, a commit without its blobs. A new repository that does not fit is refused for an hour. When a refresh of a cached one does not fit, it is served as it is and dropped once idle, so the next request clones it again with only its branches and tags; commits fetched by ID cannot leave it stuck. A repository right at the limit re-clones each time it grows past it, which is what the limit means. The drop can fall between a client'sinfo/refsandgit-upload-packrequests, so the second is served from the fresh clone; the advertisement and the pack then come from different copies, and a want for a ref that moved upstream in between fails and the client retries. A commit fetched by ID that does not fit is refused for an hour and the repository kept, so a large commit, even one named deliberately through a fork, cannot force re-clones.max_size: least recently used repositories are evicted after each fetch that takes the cache past it, and at startup. A repository is never evicted while a request or refresh is using it; it is renamed aside under the cache lock, so a later request clones it again.gitprocess outlives its fetch.Closecancels the refreshes in progress and waits for them, so none is still writing to the cache when the process exits, and refuses new ones. Fetches by ID run on request goroutines, which the HTTP server's shutdown drains first.gitis added to the image (about 13 MB).Metrics
Git reports through the existing metrics under
ecosystem="git": cache hits and misses perinfo/refs, upstream fetch durations and errors (not_found,refused,too_large,fetch_failed), and evictions.Tested
Unit and integration tests run real
gitclients against an upstream served bygit http-backend: protocols v0, v1 and v2, shallow clones, fetching into an existing clone, a cache rewound behind the upstream's back, pruning, default branch changes,ls-refsasking only for branch and tag prefixes, eight concurrent cold clones sharing one fetch, eight warm clones served while a fetch writes, stale serving and its limits, stalled ref checks and stalled transfers, credentials, address checks, redirects, size caps, eviction, gzip and chunked bodies (including a clone of 2000 tags with a smallhttp.postBuffer), a request body read past the server's read timeout, and fetch by ID. Fetch by ID is also tested for unpinning once a branch includes the commit or it ages out, for a repository filled pastmax_repo_sizebeing cloned again, for not retrying a failed commit within a minute, for staying off with credentials, for a commit too large to fetch leaving the repository and no partial objects behind, for a good commit asked for with a bad one still being fetched, and the cache directory for following the upstream URL, with only a repointed upstream's old directory removed at startup and other data incache_dirleft alone. The key checks were mutation tested.A black-box script adapted from git-cache-proxy's suite passes against both Gerrit (
mediawiki/skins/Vector) and GitHub (git-pkgs/purl), including fetching a patch set and a pull request head by ID. Formediawiki/core, the firstinfo/refstook 5 minutes 5 seconds while the proxy cloned it (796 MB), past the old write timeout, and later checks took under a second.Not in this PR
git_repositoriestable.proxy mirrorand SBOM support, and a lock shared between processes, socache_dirmust belong to one process for now.upload-packprocesses, and bitmaps or scheduled maintenance to speed up repeated full clones. Pack generation is CPU bound: eight concurrent warm clones ofmediawiki/skins/Vectortook 4.7 s, against 1.7 s for one.Worth deciding
Fetch by ID with credentials. GitHub serves a commit from any repository in a fork network through any other. With credentials and
repos: ["acme/a"], a fetch by ID throughacme/acould pull commits from a private fork outside the allowlist. The options are to document it, to turn fetch by ID off whereupstream.authapplies, or to make it opt-in. This PR turns it off where credentials apply; for public hosts it gives no access a client lacks, since anyone can fetch those commits from the host directly.403 means refused. Following Cache Git clones and fetches git-pkgs/proxy#403, a 403 is never answered from the cache. A host that rate-limits with 403 rather than 429 would fail clones during that window instead of serving the cached copy.
GitHub answers 401 for a repository that doesn't exist, so a misspelled name there returns 502 "upstream refused the request" rather than 404.
Refs git-pkgs#403