vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software: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 ./...).
internal/web/templates.go +11−2
| @@ -58,6 +58,8 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde | |||
| 58 | 58 | .diff tr.del td.ln{color:var(--bad)} | |
| 59 | 59 | .conflict{color:var(--bad)} | |
| 60 | 60 | .diff tr.hunk td{color:var(--muted);background:var(--card);padding:.25rem .5rem;font-size:.78rem} | |
| 61 | .diff tr.note td{background:rgba(110,168,254,.07);padding:.15rem .5rem .3rem;font-size:.78rem} | ||
| 62 | .diff tr.note a.label{color:var(--accent);border-color:var(--accent)} | ||
| 61 | 63 | .diff .binary{padding:.7rem .8rem;color:var(--muted);font-size:.85rem} | |
| 62 | 64 | </style> | |
| 63 | 65 | </head> | |
| @@ -226,6 +228,7 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde | |||
| 226 | 228 | <td class="ln">{{if .NewNo}}{{.NewNo}}{{end}}</td> | |
| 227 | 229 | <td class="code">{{.Text}}</td> | |
| 228 | 230 | </tr> | |
| 231 | {{if .Comments}}<tr class="note"><td colspan="3">{{range .Comments}}<a class="label" href="#c-{{.ID}}">{{.Author}} commented</a>{{end}}</td></tr>{{end}} | ||
| 229 | 232 | {{end}} | |
| 230 | 233 | {{end}} | |
| 231 | 234 | </table> | |
| @@ -325,7 +328,10 @@ labels, then the body as markdown, which you can write with any editor.</p> | |||
| 325 | 328 | <h2>{{len .Comments}} comment(s)</h2> | |
| 326 | 329 | {{range .Comments}} | |
| 327 | 330 | <div class="card" id="c-{{.ID}}"> | |
| 328 | <p class="muted"><span class="sha">{{short .ID}}</span> · {{.Author}} · {{since .Created.Unix}}</p> | ||
| 331 | <p class="muted"><span class="sha">{{short .ID}}</span> · {{.Author}} · {{since .Created.Unix}} | ||
| 332 | {{if .Anchor}}· on <code>{{.Anchor.Path}}:{{.Anchor.Line}}</code> ({{.Anchor.Side}} side) | ||
| 333 | {{if not .Current}}· <span class="conflict">the file has changed since this comment</span>{{end}}{{end}} | ||
| 334 | </p> | ||
| 329 | 335 | <article class="md">{{.Body}}</article> | |
| 330 | 336 | </div> | |
| 331 | 337 | {{else}} | |
| @@ -402,7 +408,10 @@ commit to land, plus <code>approvals/<account-id>.md</code> from each appr | |||
| 402 | 408 | <h2>Discussion</h2> | |
| 403 | 409 | {{range .Comments}} | |
| 404 | 410 | <div class="card" id="c-{{.ID}}"> | |
| 405 | <p class="muted"><span class="sha">{{short .ID}}</span> · {{.Author}} · {{since .Created.Unix}}</p> | ||
| 411 | <p class="muted"><span class="sha">{{short .ID}}</span> · {{.Author}} · {{since .Created.Unix}} | ||
| 412 | {{if .Anchor}}· on <code>{{.Anchor.Path}}:{{.Anchor.Line}}</code> ({{.Anchor.Side}} side) | ||
| 413 | {{if not .Current}}· <span class="conflict">the file has changed since this comment</span>{{end}}{{end}} | ||
| 414 | </p> | ||
| 406 | 415 | <article class="md">{{.Body}}</article> | |
| 407 | 416 | </div> | |
| 408 | 417 | {{else}} | |