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.
Let an author move their change request's head from the browser
53ff967be619894b62ca0ca69cd9a9f5f52bce01 · Keenan Nemetz · 13d ago · parent 8e1005eb
Let an author move their change request's head from the browser A request names the exact commit under review, so a fixup pushed after it was opened is invisible until the head moves. The only mover was `vole change update --head`, which writes the metadata branch — a write a contributor cannot make. The person who owns the branch was the one person who could not keep the request current. The request page now offers the author (or a maintainer) a branch picker that moves an open request's head to that branch's tip. It applies the same rule the new-request form does: the head must be a branch tip, and a contributor may only name a branch under their own namespace, which is what the pre-receive hook would have let them push. Approvals stay in the tree as history and stop matching, which is what a new head already means. SetRequestHead now refuses a closed or merged request, so the CLI and the browser share that rule instead of the web handler owning it alone.
metadata/requests_test.go +52
| @@ -481,6 +481,58 @@ func TestMarkMergedClosesLinkedIssues(t *testing.T) { | |||
| 481 | 481 | } | |
| 482 | 482 | } | |
| 483 | 483 | ||
| 484 | // A request's head is what the review and the approvals name, so it may only move | ||
| 485 | // while the request is open: a merged request's head is the record of what landed, | ||
| 486 | // and a closed one is not going to land at all. The refusal lives in the writer so | ||
| 487 | // the CLI and the browser cannot disagree about it. | ||
| 488 | func TestSetRequestHeadMovesOnlyAnOpenRequest(t *testing.T) { | ||
| 489 | t.Parallel() | ||
| 490 | root := t.TempDir() | ||
| 491 | if err := git.InitBare(root, "main"); err != nil { | ||
| 492 | t.Fatal(err) | ||
| 493 | } | ||
| 494 | g := git.Open(root) | ||
| 495 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 496 | seed, err := g.CommitTree(mustTree(t, g), "", "seed") | ||
| 497 | if err != nil { | ||
| 498 | t.Fatal(err) | ||
| 499 | } | ||
| 500 | if err := g.UpdateRef(Ref, seed); err != nil { | ||
| 501 | t.Fatal(err) | ||
| 502 | } | ||
| 503 | req, err := NewRequest("Land it", "", "keenan", "main", headSHA, now) | ||
| 504 | if err != nil { | ||
| 505 | t.Fatal(err) | ||
| 506 | } | ||
| 507 | if err := CreateRequest(g, req, "keenan"); err != nil { | ||
| 508 | t.Fatal(err) | ||
| 509 | } | ||
| 510 | |||
| 511 | got, err := SetRequestHead(g, req.ID, sideSHA, "keenan") | ||
| 512 | if err != nil || got.Head != sideSHA { | ||
| 513 | t.Fatalf("open head move = %v, %v", got.Head, err) | ||
| 514 | } | ||
| 515 | |||
| 516 | // Closed: the head is history, not a setting. | ||
| 517 | if _, err := SetRequestState(g, req.ID, "closed", "keenan"); err != nil { | ||
| 518 | t.Fatal(err) | ||
| 519 | } | ||
| 520 | if _, err := SetRequestHead(g, req.ID, headSHA, "keenan"); err == nil { | ||
| 521 | t.Fatal("a closed request's head moved") | ||
| 522 | } | ||
| 523 | |||
| 524 | // Merged: the head names what landed, so it is settled. | ||
| 525 | if _, err := SetRequestState(g, req.ID, "open", "keenan"); err != nil { | ||
| 526 | t.Fatal(err) | ||
| 527 | } | ||
| 528 | if _, _, err := MarkMerged(g, req.ID, "", sideSHA, "reviewer", now); err != nil { | ||
| 529 | t.Fatal(err) | ||
| 530 | } | ||
| 531 | if _, err := SetRequestHead(g, req.ID, headSHA, "keenan"); err == nil { | ||
| 532 | t.Fatal("a merged request's head moved") | ||
| 533 | } | ||
| 534 | } | ||
| 535 | |||
| 484 | 536 | // A link is often discovered after a request is opened, so it can be set later. The | |
| 485 | 537 | // same rules apply: a full id, an issue that exists, and only while the request can | |
| 486 | 538 | // still land. | |