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 review comment points at a line, and stays glued to it
9cc2f08757a36a49c7baa3ce502f5934341e7a5b · Keenan Nemetz · 17d ago · parent 7c6e52e7
Phase 3: a review comment points at a line, and stays glued to it A review thread can now say where it applies. A comment may carry an anchor — path, side, line and blob — on the same file it already lives in, written by the same reader and writer as before (four extra frontmatter lines, refused as a half-written set if any is missing). The interesting part is what it anchors on. A line number is not an identity: the file moves and the number points at different code. A commit id is too coarse: any unrelated change to the branch would invalidate every anchor. The **blob** of the content reviewed is content-addressed, so a comment is glued to exactly what was read: it stays current for as long as that file is unchanged — the same file at a later head has the same blob id — and becomes stale the moment it changes, rather than sliding to a line that now says something else. That is what "survive a rewrite" has to mean, and the page enforces it by resolving the blob at the head (or at the merge base, for `side: old`) and comparing. Which is also why nothing is verified at the push boundary. An anchor is a display hint, not an authorization: an anchor that lies can only make itself stale, and a rule in pre-receive would have to re-check every old anchor against a head that has since moved — rejecting pushes for comments nobody touched. `vole request comment <id> -m … --path f --line n [--side old]` computes the blob from the clone, refuses a line past the end of the file, and says `git fetch` rather than writing an anchor it cannot compute. On the page, a current anchor puts a marker under the line that links to the comment, and the card says `path:line (side)`; a stale one keeps its place in the thread with "the file has changed since this comment" and loses the marker. `side: old` covers reviewing a deletion, since that line exists only at the merge base. Verified: TestAnchorRoundTrips and TestAnchorMustBeWhole (blob is full-length, side is new or old, line is 1 or more, path stays inside the repository, and half an anchor is a refusal naming the file); and end to end through the binary, TestReviewCommentAnchorsALine — a new-side comment whose blob matches `rev-parse <head>:code.txt` on the server, the marker and the labelled card on the page, an old-side comment whose blob matches the merge base, a line past the end refused with the branch unmoved, and then a new head that changes the file, after which the new-side comment is labelled stale and loses its marker while the old-side one does not. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
docs/roadmap.md +21−7
| @@ -213,7 +213,7 @@ 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 (line-anchored review comments deferred) | ||
| 216 | ### Phase 3 — Change requests — ✅ complete | ||
| 217 | 217 | ||
| 218 | 218 | ✅ **Done.** A change request is a directory on `refs/heads/vole`: | |
| 219 | 219 | ||
| @@ -254,13 +254,26 @@ audited), and the page renders the thread with an anchor per comment, so a link | |||
| 254 | 254 | one remark is a URL. Until this landed the only ways to say something about a | |
| 255 | 255 | request were editing its body or approving it. | |
| 256 | 256 | ||
| 257 | Deferred: | ||
| 257 | ✅ **Line anchors, since this phase closed.** A comment may name the line it is | ||
| 258 | about, on either side of the diff: | ||
| 258 | 259 | ||
| 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. | ||
| 260 | ``` | ||
| 261 | path: internal/hook/hook.go | ||
| 262 | side: new # new = the head's file, old = the merge base's | ||
| 263 | line: 412 | ||
| 264 | blob: 3f2a… # the id of the content reviewed | ||
| 265 | ``` | ||
| 266 | |||
| 267 | `vole request comment <id> -m "…" --path f --line n [--side old]` computes the blob | ||
| 268 | from the clone (refusing a line past the end of the file, or a head that is not | ||
| 269 | there), and the request page marks the line with a link to the comment. The anchor | ||
| 270 | is on the **blob**, not on a commit or a line number, and that is what makes | ||
| 271 | "survive a rewrite" true: the same file at a later head has the same blob id, so a | ||
| 272 | comment stays current while the file is untouched and becomes *stale* — labelled, | ||
| 273 | marker removed — the moment it changes, rather than sliding to a line that now says | ||
| 274 | something else. The page answers that question itself by resolving the blob at the | ||
| 275 | head (or the merge base) and comparing, so nothing has to be verified at push time: | ||
| 276 | an anchor that lies can only make itself stale. | ||
| 264 | 277 | - The *collapsible* part of the per-file diff. The diff itself landed in Phase 7 | |
| 265 | 278 | slice 1 (three-dot range, per-file hunks, line numbers), and required status | |
| 266 | 279 | checks landed in Phase 4 slice 3, so both are struck off the original list | |
| @@ -817,6 +830,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 817 | 830 | | 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 | | |
| 818 | 831 | | 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 | | |
| 819 | 832 | | 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 | | |
| 833 | | 2026-10-23 | A review comment anchors on the blob of the content reviewed, and the page decides whether it is stale | A line number is not an identity: the file moves and the number points at different code. A commit id is too coarse — any unrelated change to the branch would invalidate every anchor. The blob is content-addressed, so a comment stays glued to exactly what was read, current for as long as the file is unchanged, and an anchor that lies can only make itself stale. It also means nothing has to be checked at the push boundary: the page resolves the blob and compares, so a later head never retroactively rejects an old comment | | ||
| 820 | 834 | | 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 | | |
| 821 | 835 | | 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 | | |
| 822 | 836 | ||