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 2, first slice: issues as files on a branch
37c01e5aa9b98bbea8567e741cd3af508ae1ba7d · Keenan Nemetz · 17d ago · parent 197ecaa2
Phase 2, first slice: issues as files on a branch Push-based issues, per the decision to keep the web read-only. The format is the contract; nothing here requires a Vole binary on a contributor's machine. issues/<ulid>/meta.json issues/<ulid>/description.md issues/<ulid>/comments/<ulid>.md on refs/heads/issues Design points worth recording: - IDs are ULIDs generated by the writer, so there is no server-assigned counter, no contention, and no ID allocation in the write path at all. - Issues live on a branch rather than a custom ref, so a plain `git clone` carries them. That is the same lesson that moved the user roster off refs/vole/users: `git clone` only mirrors refs/heads/*. - File-per-comment means two people replying to the same issue never touch the same file, so the only merge conflict is two edits to one comment. - Validation happens in pre-receive, so a malformed tree never reaches the web. Every problem is reported at once with the offending path, and a rejected push leaves the branch unmoved. The index gained issue rows, refreshed when the issues ref moves rather than by a hook, plus coverage in the full rebuild. The list page reads the index; the detail page reads git, so bodies and comments are never stale. Markdown is rendered with goldmark and sanitised with bluemonday. Issue bodies arrive by push, which makes them untrusted input on a public page; a test asserts that <script> and javascript: URLs do not survive. Verified by hand with a user who has an editor and a key: filed an issue, added a comment, saw both rendered, then pushed an issue with no title and got back "issues/01M5.../meta.json: title is required" with the branch unmoved.
docs/roadmap.md +36−21
| @@ -124,27 +124,40 @@ Deviations from the original plan, and why: | |||
| 124 | 124 | - **Exec refusals are answered on the channel**, not by rejecting the request, | |
| 125 | 125 | so the client can read the reason instead of `exec request failed on channel 0`. | |
| 126 | 126 | ||
| 127 | ### Phase 2 — Metadata layer and issues | ||
| 128 | |||
| 129 | The phase where the core bet is either proven or abandoned. | ||
| 130 | |||
| 131 | - Metadata tree schema: file-per-entity, **256 hash-sharded refs** (spike A), stable IDs | ||
| 132 | generated as monotonic counters committed alongside the entities | ||
| 133 | - One CAS commit helper that every metadata writer goes through: shard-local batching on a | ||
| 134 | short timer, retry with jitter on a lost race, and a bounded write queue. No other code | ||
| 135 | path may move a metadata ref | ||
| 136 | - Derived index with incremental update on ref change and full rebuild; every query path | ||
| 137 | works from a cold index | ||
| 138 | - Issues: create, comment, edit, close/reopen, labels, assignees, cross-references | ||
| 139 | (`#12`), full-text search over the index | ||
| 140 | - Markdown rendering with HTML sanitization | ||
| 141 | - Notifications: in-app first, email via SMTP config second | ||
| 142 | |||
| 143 | **Exit criteria:** deleting the SQLite file and running `vole index rebuild` reproduces every | ||
| 144 | issue byte-for-byte (diffed, not eyeballed); 32 concurrent commenters lose zero comments; | ||
| 145 | `git fetch 'refs/vole/*:refs/vole/*'` on a fresh clone yields a browsable issue history with | ||
| 146 | `git log`; sustained ≥500 writes/s at 100k entities **including the index update** — spike A | ||
| 147 | measured the ref path alone, so the index is the unmeasured half and must be proven. | ||
| 127 | ### Phase 2 — Metadata layer and issues — 🚧 first slice shipped | ||
| 128 | |||
| 129 | **The design changed before building.** Phase 2 was written around server-side | ||
| 130 | writers: sharded metadata refs, batched CAS, counters assigned by the server. | ||
| 131 | That needs an authenticated write surface, and the web is now read-only. Issues | ||
| 132 | are therefore **push-based**: content lives on a branch, written by the people who | ||
| 133 | file them, validated at the push boundary. The Phase 0 sharding work is not wasted | ||
| 134 | (change requests will still want it), but nothing on the issue path depends on it. | ||
| 135 | |||
| 136 | Shipped: | ||
| 137 | |||
| 138 | - **Format**: `issues/<ulid>/meta.json`, `description.md`, and | ||
| 139 | `comments/<ulid>.md`, on `refs/heads/issues`. ULIDs are generated by the writer, | ||
| 140 | so there is no counter to assign and no contention; it is a branch rather than a | ||
| 141 | custom ref so that a plain `git clone` carries the issues. | ||
| 142 | - **Validation in `pre-receive`**: ULID directory names, required fields, valid | ||
| 143 | states and labels, comment frontmatter, and no paths outside the format. Every | ||
| 144 | problem is reported in one rejection, with the offending path named. | ||
| 145 | - **Derived index**: issue rows refreshed when the issues ref moves, plus the | ||
| 146 | existing full rebuild. The detail page reads from git so bodies are never stale. | ||
| 147 | - **Read-only rendering**: list, thread, GFM markdown sanitised with bluemonday — | ||
| 148 | issue bodies arrive by push, so they are untrusted input on a public page. | ||
| 149 | |||
| 150 | Remaining: | ||
| 151 | |||
| 152 | - `vole issue` CLI as sugar: allocate a ULID, scaffold the files, commit, push | ||
| 153 | - State changes are hand edits today (closing an issue is editing `state`) | ||
| 154 | - Cross-references (`#<ulid>`) and backlinks from commits | ||
| 155 | - Filtering by label/assignee and search over the derived index | ||
| 156 | - Notifications: in-app first, then email | ||
| 157 | |||
| 158 | Exit criteria: a user with an editor and a key files, comments on and closes an | ||
| 159 | issue without installing Vole; an invalid tree is rejected with the offending path | ||
| 160 | named and the branch unmoved; deleting the index loses no issues. | ||
| 148 | 161 | ||
| 149 | 162 | ### Phase 3 — Change requests | |
| 150 | 163 | ||
| @@ -294,6 +307,8 @@ Filled in as phases land. Append; don't rewrite. | |||
| 294 | 307 | | 2026-10-22 | Identity in `system.git`, roster on `refs/heads/main` | Public keys only; a plain clone is a complete, non-leaking backup | | |
| 295 | 308 | | 2026-10-22 | Roster replaced by a `trusted_keys/` directory | The git-backed version carried unused fields and ~280 lines of CAS/tree machinery; the only real loss was audit, now `data/audit.log` | | |
| 296 | 309 | | 2026-10-22 | `receive.maxInputSize` replaces the HTTP byte cap | git enforces it on both transports; the proxy never buffers a pack | | |
| 310 | | 2026-10-22 | Issues are push-based on `refs/heads/issues`, IDs are client-generated ULIDs | No authenticated web write surface exists, and a branch makes issues travel with a clone | | ||
| 311 | | 2026-10-22 | Issue trees validated in `pre-receive` | Reject bad data at the boundary instead of rendering it | | ||
| 297 | 312 | ||
| 298 | 313 | ## Open questions | |
| 299 | 314 | ||