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/requests.go +68−1
| @@ -132,13 +132,80 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 132 | 132 | } | |
| 133 | 133 | // The review thread. Comments arrive by push, so they cross the same markdown | |
| 134 | 134 | // boundary every other pushed body does — sanitised, never rendered raw. | |
| 135 | // | ||
| 136 | // An anchored comment names a line and the blob it saw. Whether it is still | ||
| 137 | // about that content is answered here rather than trusted from the file: one | ||
| 138 | // rev-parse per distinct (side, path) is a handful for a review thread, and it | ||
| 139 | // is exact where the diff's abbreviated ids would need prefix matching. | ||
| 135 | 140 | type renderedComment struct { | |
| 136 | 141 | requests.Comment | |
| 137 | 142 | Body template.HTML | |
| 143 | // Current reports whether the file still holds the content this comment was | ||
| 144 | // written about. A stale comment keeps its place in the thread and loses its | ||
| 145 | // marker on the diff, because a marker on a line that has changed points at | ||
| 146 | // something the comment never saw. | ||
| 147 | Current bool | ||
| 148 | } | ||
| 149 | head := req.Head | ||
| 150 | baseTip := "" | ||
| 151 | if req.Base != "" { | ||
| 152 | if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil { | ||
| 153 | baseTip = tip | ||
| 154 | } | ||
| 155 | } | ||
| 156 | mergeBase := "" | ||
| 157 | if baseTip != "" && head != "" { | ||
| 158 | if mb, err := m.Git().MergeBase(baseTip, head); err == nil { | ||
| 159 | mergeBase = mb | ||
| 160 | } | ||
| 161 | } | ||
| 162 | blobs := map[string]string{} | ||
| 163 | currentBlob := func(side, path string) string { | ||
| 164 | key := side + "|" + path | ||
| 165 | if blob, ok := blobs[key]; ok { | ||
| 166 | return blob | ||
| 167 | } | ||
| 168 | commit := head | ||
| 169 | if side == "old" { | ||
| 170 | commit = mergeBase | ||
| 171 | } | ||
| 172 | blob := "" | ||
| 173 | if commit != "" { | ||
| 174 | blob, _ = m.Git().RevParseAny(commit + ":" + path) | ||
| 175 | } | ||
| 176 | blobs[key] = blob | ||
| 177 | return blob | ||
| 138 | 178 | } | |
| 139 | 179 | comments := make([]renderedComment, 0, len(req.Comments)) | |
| 140 | 180 | for _, cm := range req.Comments { | |
| 141 | comments = append(comments, renderedComment{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)}) | ||
| 181 | rc := renderedComment{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)} | ||
| 182 | if cm.Anchor != nil { | ||
| 183 | rc.Current = currentBlob(cm.Anchor.Side, cm.Anchor.Path) == cm.Anchor.Blob | ||
| 184 | } | ||
| 185 | comments = append(comments, rc) | ||
| 186 | } | ||
| 187 | // A marker on the line each current anchored comment is about. The side decides | ||
| 188 | // which path and which line number: a comment on a deleted line belongs to the | ||
| 189 | // file as it stood at the merge base. | ||
| 190 | for i := range files { | ||
| 191 | f := &files[i] | ||
| 192 | for h := range f.Hunks { | ||
| 193 | lines := f.Hunks[h].Lines | ||
| 194 | for l := range lines { | ||
| 195 | for _, rc := range comments { | ||
| 196 | if rc.Anchor == nil || !rc.Current { | ||
| 197 | continue | ||
| 198 | } | ||
| 199 | path, no := f.NewPath, lines[l].NewNo | ||
| 200 | if rc.Anchor.Side == "old" { | ||
| 201 | path, no = f.OldPath, lines[l].OldNo | ||
| 202 | } | ||
| 203 | if path == rc.Anchor.Path && no == rc.Anchor.Line { | ||
| 204 | lines[l].Comments = append(lines[l].Comments, diffComment{ID: rc.ID, Author: rc.Author}) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | } | ||
| 142 | 209 | } | |
| 143 | 210 | added, removed := diffSummary(files) | |
| 144 | 211 | ||