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 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