vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 6 slice 1: the tree is the site
d3296cda0c18f78097e37373822292707113d245 · Keenan Nemetz · 17d ago · parent 64ac5071
Phase 6 slice 1: the tree is the site A repository's `pages` branch is served at <repo>.<pages_domain>, straight out of git. No export step, no build to drift: the push moves the branch and the next request reads the new tree. - internal/pages: a pool of `git cat-file --batch` processes per active repository (closed after two idle minutes), an LRU keyed by (commit, path), and tree parsing in git's binary format with the object-id width detected rather than assumed - ETag is the blob sha, Last-Modified the commit time, Content-Type from the extension or a sniff; If-None-Match and If-Modified-Since answer 304 - directories serve index.html and redirect to add the trailing slash; path resolution walks trees, so a traversal-shaped request is a missing page - repository and ACL are resolved once per one-second memo window together with the branch tip, which is what makes the read path fast: doing it per request spawned three git processes and cost 3.8 ms, against 6.7 µs warm - sites follow clone visibility, so a private repository answers 404; the repo page links to the site when pages_domain is configured Measured: a site pushed over SSH is live in 1.4 s (the phase criterion is 5 s); warm requests 6.7 µs, cold 144 µs. Custom domains and their TLS certificates are the remaining Phase 6 work.
docs/roadmap.md +31−13
| @@ -340,19 +340,37 @@ through history, and against an archived CI log). | |||
| 340 | 340 | ||
| 341 | 341 | ### Phase 6 — Site hosting | |
| 342 | 342 | ||
| 343 | - A designated pages ref per repo, served as static files directly from the git tree — no | ||
| 344 | export step (spike C). A pool of `git cat-file --batch` processes (default 4; 1→4 doubled | ||
| 345 | throughput) with a read-through in-memory blob cache keyed by `(commit, path)` | ||
| 346 | - Cache is **required, not an optimisation**: cold git reads are ~1.2 ms p50, warm is | ||
| 347 | 0.075 ms. LRU eviction keyed by commit, since a busy pages ref would otherwise grow it | ||
| 348 | - Per-repo hostname (`<repo>.<instance-domain>`) plus optional custom domain | ||
| 349 | - Automatic TLS via ACME when a domain is configured, with DNS-01 for wildcards | ||
| 350 | - ETag/`Last-Modified` from blob SHAs; correct `Content-Type` from extension and content sniff | ||
| 351 | - Cache invalidation keyed by commit, so there is no rebuild step to drift | ||
| 352 | |||
| 353 | **Exit criteria:** a pushed doc tree is live in under 5 seconds; conditional requests return | ||
| 354 | `304`; a custom domain gets a certificate with no manual steps; serving a repo with a | ||
| 355 | path-traversal-shaped filename is safe. | ||
| 343 | ✅ **Slice 1 — the tree is the site.** A repository's `pages` branch is served | ||
| 344 | directly out of git at `<repo>.<pages_domain>`: no export step, no build to drift. | ||
| 345 | Blobs come from a pool of `git cat-file --batch` processes (default 4, per active | ||
| 346 | repository, closed after two idle minutes) and results are cached by `(commit, path)` | ||
| 347 | in an LRU. `ETag` is the blob sha, `Last-Modified` the commit time, `Content-Type` | ||
| 348 | comes from the extension or a content sniff, and conditional requests answer `304`. | ||
| 349 | A directory serves its `index.html`, and a directory URL without a trailing slash | ||
| 350 | redirects so relative links keep working. Path resolution walks trees in memory, so a | ||
| 351 | traversal-shaped URL is simply a missing page — it never reaches the filesystem. | ||
| 352 | Sites follow clone visibility: a private repository has no site, because there is no | ||
| 353 | session with which an anonymous reader could authenticate. The ref tip *and* the | ||
| 354 | authorization decision are resolved together and memoised for one second, and a moved | ||
| 355 | tip drops the previous commit's cache entries, so a push is live well inside the | ||
| 356 | five-second criterion. (Measured on this implementation: 6.7 µs per warm request, | ||
| 357 | 144 µs with the caches cleared — the difference between a docs site that feels like a | ||
| 358 | file server and one that feels like a database. Resolving the repository and its ACL | ||
| 359 | per request cost 3.8 ms, because it spawned three `git` subprocesses; that is why both | ||
| 360 | are resolved once per memo window.) | ||
| 361 | |||
| 362 | Remaining: | ||
| 363 | |||
| 364 | - Custom domains per repository, and automatic TLS (ACME) for them | ||
| 365 | - A per-repository pages ref; today `pages_ref` is one setting for the instance | ||
| 366 | - The `pages` branch has no review story of its own: it is a normal branch, so | ||
| 367 | protecting it is `vole acl protect` | ||
| 368 | |||
| 369 | **Exit criteria:** a pushed doc tree is live in under 5 seconds ✅ (measured: pushed | ||
| 370 | over SSH, served on the site host, polled until the new bytes appeared); conditional | ||
| 371 | requests return `304` ✅; a custom domain gets a certificate with no manual steps ❌ | ||
| 372 | (needs the ACME slice); serving a repo with a path-traversal-shaped filename is safe ✅ | ||
| 373 | (and so is a traversal-shaped *request*). | ||
| 356 | 374 | ||
| 357 | 375 | ### Phase 7 — Signing and audit | |
| 358 | 376 | ||