Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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