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.
Show which branch a change request came from
8e1005eb2c104934adef950c5afc090d4025ce06 · Keenan Nemetz · 13d ago · parent b7ac1a00
Show which branch a change request came from A request records the exact commit under review, and the page showed only that: `main ← 43f98bcd`. The branch the work is on is how people talk about a change, fetch it, and find it after it lands, and it was being thrown away when the request was opened — the web form resolved the chosen branch to a commit and kept only the commit. `request.md` now records `head_ref:` beside `head:`. It is a display hint and nothing else: approvals, checks, the diff and the landing gate still name the commit, and a test pins that a branch name cannot authorise a different tip. The request page, the request list and `vole change show` show it, and the page marks the branch as moved or deleted when it no longer points at the reviewed commit, so the name reads as provenance rather than as a claim about what is being reviewed. It is optional on read: records written before it existed still load, and a request opened from a bare commit id records no branch.
metadata/requests_test.go +92
| @@ -256,6 +256,98 @@ func TestMergedStateNamesTheCommitThatLanded(t *testing.T) { | |||
| 256 | 256 | } | |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | // A request may record the branch its head came from. It is provenance, not | ||
| 260 | // authorisation: the commit is what the gate matches, so a branch that has moved on | ||
| 261 | // must not carry the request's approval to the new tip. The field is optional so | ||
| 262 | // records written before it existed still load, and it must be a branch name with a | ||
| 263 | // commit behind it. | ||
| 264 | func TestHeadRefIsAHeadingNotAnApproval(t *testing.T) { | ||
| 265 | t.Parallel() | ||
| 266 | body := func(extra string) map[string]string { | ||
| 267 | return map[string]string{ | ||
| 268 | RequestsDir + "/" + reqIDA + "/request.md": "---\n" + | ||
| 269 | `title: "Land the retry"` + "\n" + | ||
| 270 | "state: open\n" + | ||
| 271 | "author: keenan\n" + | ||
| 272 | "base: main\n" + | ||
| 273 | "head: " + headSHA + "\n" + | ||
| 274 | extra + | ||
| 275 | "created: 2026-10-22T22:00:00Z\n" + | ||
| 276 | "---\n", | ||
| 277 | } | ||
| 278 | } | ||
| 279 | list, err := ParseRequests(body("head_ref: contrib/keenan/fix\n")) | ||
| 280 | if err != nil { | ||
| 281 | t.Fatalf("a request naming its branch was refused: %v", err) | ||
| 282 | } | ||
| 283 | if list[0].HeadRef != "contrib/keenan/fix" { | ||
| 284 | t.Fatalf("head_ref = %q, want contrib/keenan/fix", list[0].HeadRef) | ||
| 285 | } | ||
| 286 | for name, extra := range map[string]string{ | ||
| 287 | "a full ref": "head_ref: refs/heads/main\n", | ||
| 288 | "a space": "head_ref: two words\n", | ||
| 289 | } { | ||
| 290 | if _, err := ParseRequests(body(extra)); err == nil { | ||
| 291 | t.Errorf("head_ref with %s was accepted", name) | ||
| 292 | } | ||
| 293 | } | ||
| 294 | noHead := map[string]string{ | ||
| 295 | RequestsDir + "/" + reqIDA + "/request.md": "---\n" + | ||
| 296 | `title: "Land the retry"` + "\n" + | ||
| 297 | "state: open\n" + | ||
| 298 | "author: keenan\n" + | ||
| 299 | "base: main\n" + | ||
| 300 | "head_ref: main\n" + | ||
| 301 | "created: 2026-10-22T22:00:00Z\n" + | ||
| 302 | "---\n", | ||
| 303 | } | ||
| 304 | if _, err := ParseRequests(noHead); err == nil { | ||
| 305 | t.Error("a branch with no head commit was accepted") | ||
| 306 | } | ||
| 307 | |||
| 308 | // The writer round-trips the field, and refuses what the reader would refuse. | ||
| 309 | now := time.Date(2026, 10, 22, 22, 0, 0, 0, time.UTC) | ||
| 310 | req, err := NewRequest("Land it", "", "keenan", "main", headSHA, now) | ||
| 311 | if err != nil { | ||
| 312 | t.Fatal(err) | ||
| 313 | } | ||
| 314 | req.HeadRef = "contrib/keenan/fix" | ||
| 315 | raw, err := renderRequest(req) | ||
| 316 | if err != nil { | ||
| 317 | t.Fatal(err) | ||
| 318 | } | ||
| 319 | again, err := ParseRequests(map[string]string{RequestsDir + "/" + req.ID + "/request.md": string(raw)}) | ||
| 320 | if err != nil { | ||
| 321 | t.Fatalf("the writer produced a request the reader refuses: %v", err) | ||
| 322 | } | ||
| 323 | if again[0].HeadRef != req.HeadRef { | ||
| 324 | t.Fatalf("round trip head_ref = %q, want %q", again[0].HeadRef, req.HeadRef) | ||
| 325 | } | ||
| 326 | bad, err := NewRequest("Land it", "", "keenan", "main", headSHA, now) | ||
| 327 | if err != nil { | ||
| 328 | t.Fatal(err) | ||
| 329 | } | ||
| 330 | bad.HeadRef = "refs/heads/main" | ||
| 331 | if err := validateRequestForWrite(bad); err == nil { | ||
| 332 | t.Error("the writer accepted a head_ref that is not a branch name") | ||
| 333 | } | ||
| 334 | |||
| 335 | // The gate matches the commit. A branch name on the request authorises nothing. | ||
| 336 | reqs := []*Request{{ | ||
| 337 | ID: reqIDA, Title: "Land it", Author: "keenan", Base: "main", | ||
| 338 | Head: headSHA, HeadRef: "contrib/keenan/fix", State: "open", | ||
| 339 | Approvals: []Approval{{Principal: "wilbur", Base: "main", Head: headSHA}}, | ||
| 340 | }} | ||
| 341 | m, err := FindApprovedMatch(nil, reqs, ApprovalRequirement{Branch: "main", NewTip: headSHA, Required: 1}) | ||
| 342 | if err != nil || m == nil { | ||
| 343 | t.Fatalf("the approved head did not match: %v, %v", m, err) | ||
| 344 | } | ||
| 345 | m, err = FindApprovedMatch(nil, reqs, ApprovalRequirement{Branch: "main", NewTip: sideSHA, Required: 1}) | ||
| 346 | if err != nil || m != nil { | ||
| 347 | t.Fatalf("a branch name authorised %s: %v, %v", sideSHA, m, err) | ||
| 348 | } | ||
| 349 | } | ||
| 350 | |||
| 259 | 351 | // "merged" is not settable through the generic state writer: it has to travel with | |
| 260 | 352 | // the commit, so MarkMerged is the only way in. | |
| 261 | 353 | func TestMergedIsNotAGenericState(t *testing.T) { | |