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 3: a change request can be discussed
7c6e52e75f3a33be57a81663bc840edd6e14f7f9 · Keenan Nemetz · 17d ago · parent 3c5d11fa
Phase 3: a change request can be discussed Until now the only ways to say something about a request were editing its body or approving it. A review thread is now a pushed file like everything else: requests/<ulid>/comments/<ulid>.md author + created, then the body It is the *same file shape* as a comment on an issue and is read and written by the same code (`issues.ParseComment`, `issues.RenderComment`, with `requests.Comment` as an alias), so the thread inherited the validation, the markdown boundary and the ownership rule instead of reimplementing them. That last one is the part worth having: `pre-receive` compares the author on the old blob to the pushing account, so a review comment is its author's to edit or delete, an admin may repair a thread (audited), and rewriting the author line in the same push does not launder it. `vole request comment <id> -m` prints the comment's id and the page anchor (`#c-<ulid>`), `edit-comment` and `rm-comment` are the author's own words, and `request show` prints the thread so the ids are discoverable at all — an id nobody can see is an id nobody can use. Adding a comment is free, like adding one to an issue: a new file's author is a claim, and the boundary has never pretended otherwise. The request's own file stays outside the ownership rule, for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit. Verified: TestParseReadsTheDiscussion (a thread plus an approval in one tree, ordered by id), TestCommentNeedsAnAuthorAndATime and TestUnknownFileInARequestIsRefused (the subtree stays a closed format, with both expected shapes named), TestCommentPaths (a request comment is not an issue comment and not an approval); and end to end through the binary, TestRequestDiscussionIsPushedAndOwned — the empty state prints the command, the CLI adds a comment, the page renders it with its anchor and `request show` lists it, a writer is refused rewriting, adopting and deleting somebody else's comment, may join the thread and may edit his own — with the issue-comment empty state assertion added to TestIssuePushedThenRendered earlier. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
docs/roadmap.md +18−5
| @@ -213,13 +213,14 @@ Exit criteria: a user with an editor and a key files, comments on and closes an | |||
| 213 | 213 | issue without installing Vole; an invalid tree is rejected with the offending path | |
| 214 | 214 | named and the branch unmoved; deleting the index loses no issues. | |
| 215 | 215 | ||
| 216 | ### Phase 3 — Change requests — ✅ complete (review comments deferred; diffs landed in Phase 7) | ||
| 216 | ### Phase 3 — Change requests — ✅ complete (line-anchored review comments deferred) | ||
| 217 | 217 | ||
| 218 | 218 | ✅ **Done.** A change request is a directory on `refs/heads/vole`: | |
| 219 | 219 | ||
| 220 | 220 | ``` | |
| 221 | 221 | requests/<ulid>/request.md title, author, base branch, head commit, state, then the body | |
| 222 | 222 | requests/<ulid>/approvals/<principal>.md base + head + created, no body | |
| 223 | requests/<ulid>/comments/<ulid>.md author + created, then the discussion | ||
| 223 | 224 | ``` | |
| 224 | 225 | ||
| 225 | 226 | - **Approvals are bound to one commit and one branch.** `git push` is the merge, so the | |
| @@ -243,12 +244,23 @@ requests/<ulid>/approvals/<principal>.md base + head + created, no body | |||
| 243 | 244 | having refused forgeries. Publishing signed attestations (Phase 7) is what would make | |
| 244 | 245 | it verifiable offline. | |
| 245 | 246 | ||
| 247 | ✅ **Discussion, since this phase closed.** A request can be talked about without | ||
| 248 | inventing a format: a comment on a request is the *same file shape* as a comment on | ||
| 249 | an issue — `requests/<ulid>/comments/<ulid>.md`, author and created in frontmatter, | ||
| 250 | body as markdown — so one reader, one writer and one ownership rule cover both. | ||
| 251 | `vole request comment <id> -m` pushes it like everything else, `edit-comment` and | ||
| 252 | `rm-comment` are the author's own words to change (an admin may repair a thread, | ||
| 253 | audited), and the page renders the thread with an anchor per comment, so a link to | ||
| 254 | one remark is a URL. Until this landed the only ways to say something about a | ||
| 255 | request were editing its body or approving it. | ||
| 256 | |||
| 246 | 257 | Deferred: | |
| 247 | 258 | ||
| 248 | - Review comments, line-anchored on `(path, blob-sha, line)` so they survive a | ||
| 249 | rewrite. There is no discussion on a request at all yet — the only ways to say | ||
| 250 | something about one are editing its body or approving it — so this is two | ||
| 251 | features: a thread, and then anchors on it. | ||
| 259 | - Line anchors on review comments: `(path, side, line)` plus the blob id of the | ||
| 260 | content reviewed, so a comment stays glued to what it was written about and is | ||
| 261 | marked outdated when that file changes rather than sliding down the page. The | ||
| 262 | thread above is the substrate, and the diff renderer already numbers both sides | ||
| 263 | of every line. | ||
| 252 | 264 | - The *collapsible* part of the per-file diff. The diff itself landed in Phase 7 | |
| 253 | 265 | slice 1 (three-dot range, per-file hunks, line numbers), and required status | |
| 254 | 266 | checks landed in Phase 4 slice 3, so both are struck off the original list | |
| @@ -805,6 +817,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 805 | 817 | | 2026-10-23 | The author of a metadata write is the account the key resolves to, asked with `vole-whoami`; `--author` is gone | `git config user.name` is a name somebody typed for commits: "Keenan Nemetz" sanitises to `keenan-nemetz`, which is not an account, so every issue filed from a normal machine was attributed to nobody and nothing complained. The instance is the only place that maps a key to an account, and it is one SSH verb away on the connection already open. A fallback exists for `--no-push` with the instance unreachable, and it warns loudly rather than guessing silently | | |
| 806 | 818 | | 2026-10-23 | A remote given as an SSH URL is fetched like a named one | Skipping the fetch for a URL is what made `--remote ssh://…` write on a branch that had never been updated: the first write in a fresh repository succeeded (nothing to fast-forward from) and every later one failed as a "lost race" that never happened, re-applying on the same stale tip and reporting "already exists". `git init` plus a URL is now the real no-clone filing path | | |
| 807 | 819 | | 2026-10-23 | The instance tells the client where it is served (`public_url`), so a command can print the page it wrote | A command that answers with an id leaves the reader to assemble a URL, and the instance is the only party that knows its own address behind a proxy. Optional: unset means ids only, which is what it printed before | | |
| 820 | | 2026-10-23 | A review comment is an issue-comment file inside the request, not a new format | `requests/<ulid>/comments/<ulid>.md` is parsed by the same code as `issues/<ulid>/comments/<ulid>.md` (`issues.ParseComment`), so the thread inherited the validation, the author-edits-own-words rule and the markdown boundary instead of reimplementing them. A request's own file stays outside the ownership rule for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit | | ||
| 808 | 821 | | 2026-10-23 | "Would this merge?" is answered in memory and surfaced, never gated | `merge-tree --write-tree` merges without a worktree or an index, so the cost is one git process on a page view; gating on it would be wrong because the base can move after an approval, and the roadmap asked for detection before the push rather than a second refusal. The exit status alone is ambiguous — 1 is both "conflicts" and "bad revision" — so the answer is believed only when the output starts with a tree id, and silence otherwise | | |
| 809 | 822 | ||
| 810 | 823 | ## Open questions | |