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.
Make change requests follow branches and allow base retargeting
e050e14a64f6f19c1b1478c4c073d690ef3d3129 · Keenan Nemetz · 13d ago · parent 59464445
Make change requests follow branches and allow base retargeting A request opened from a branch now follows it: post-receive moves the request's head to the new tip after an accepted push, so approvals and checks that named the old tip stop counting. Approvals stay bound to an exact base and commit; the approval writers now require the base and head the approver saw, so a push or retarget cannot silently approve a target nobody reviewed. Merged requests freeze the reviewed head. Add `vole change update --base` and a Change base control for stacked requests. A new base changes the diff and therefore invalidates approvals, which is what retargeting an upper request after its lower branch lands needs. Base names may now contain slashes. Also fix a second-boundary flake in the landing test.
metadata/requests_test.go +245−9
| @@ -450,7 +450,7 @@ func TestMarkMergedClosesLinkedIssues(t *testing.T) { | |||
| 450 | 450 | t.Fatal("a request closing a non-existent issue was written") | |
| 451 | 451 | } | |
| 452 | 452 | ||
| 453 | got, closed, err := MarkMerged(g, req.ID, sideSHA, headSHA, "reviewer", now) | ||
| 453 | got, closed, err := MarkMerged(g, req.ID, sideSHA, headSHA, headSHA, "reviewer", now) | ||
| 454 | 454 | if err != nil { | |
| 455 | 455 | t.Fatalf("mark: %v", err) | |
| 456 | 456 | } | |
| @@ -471,7 +471,7 @@ func TestMarkMergedClosesLinkedIssues(t *testing.T) { | |||
| 471 | 471 | } | |
| 472 | 472 | // Marking again is a no-op, so a retried landing does not re-close anything. | |
| 473 | 473 | // A retry that cannot name the base keeps the one the record already has. | |
| 474 | if _, again, err := MarkMerged(g, req.ID, "", headSHA, "reviewer", now); err != nil || len(again) != 0 { | ||
| 474 | if _, again, err := MarkMerged(g, req.ID, "", headSHA, headSHA, "reviewer", now); err != nil || len(again) != 0 { | ||
| 475 | 475 | t.Fatalf("second mark = %v, %v", again, err) | |
| 476 | 476 | } | |
| 477 | 477 | if again, err := GetRequest(g, req.ID); err != nil { | |
| @@ -508,16 +508,16 @@ func TestSetRequestHeadMovesOnlyAnOpenRequest(t *testing.T) { | |||
| 508 | 508 | t.Fatal(err) | |
| 509 | 509 | } | |
| 510 | 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) | ||
| 511 | got, err := SetRequestHead(g, req.ID, sideSHA, "feature", "keenan") | ||
| 512 | if err != nil || got.Head != sideSHA || got.HeadRef != "feature" { | ||
| 513 | t.Fatalf("open head move = %s on %q, %v; want %s on feature", got.Head, got.HeadRef, err, sideSHA) | ||
| 514 | 514 | } | |
| 515 | 515 | ||
| 516 | 516 | // Closed: the head is history, not a setting. | |
| 517 | 517 | if _, err := SetRequestState(g, req.ID, "closed", "keenan"); err != nil { | |
| 518 | 518 | t.Fatal(err) | |
| 519 | 519 | } | |
| 520 | if _, err := SetRequestHead(g, req.ID, headSHA, "keenan"); err == nil { | ||
| 520 | if _, err := SetRequestHead(g, req.ID, headSHA, "", "keenan"); err == nil { | ||
| 521 | 521 | t.Fatal("a closed request's head moved") | |
| 522 | 522 | } | |
| 523 | 523 | ||
| @@ -525,14 +525,250 @@ func TestSetRequestHeadMovesOnlyAnOpenRequest(t *testing.T) { | |||
| 525 | 525 | if _, err := SetRequestState(g, req.ID, "open", "keenan"); err != nil { | |
| 526 | 526 | t.Fatal(err) | |
| 527 | 527 | } | |
| 528 | if _, _, err := MarkMerged(g, req.ID, "", sideSHA, "reviewer", now); err != nil { | ||
| 528 | if _, _, err := MarkMerged(g, req.ID, "", sideSHA, sideSHA, "reviewer", now); err != nil { | ||
| 529 | 529 | t.Fatal(err) | |
| 530 | 530 | } | |
| 531 | if _, err := SetRequestHead(g, req.ID, headSHA, "keenan"); err == nil { | ||
| 531 | if _, err := SetRequestHead(g, req.ID, headSHA, "", "keenan"); err == nil { | ||
| 532 | 532 | t.Fatal("a merged request's head moved") | |
| 533 | 533 | } | |
| 534 | 534 | } | |
| 535 | 535 | ||
| 536 | // Retargeting a stacked request changes the base and therefore the diff, so every | ||
| 537 | // approval that named the old base stops counting. A base may contain a slash, which | ||
| 538 | // is what a stacked request on another feature branch usually needs. | ||
| 539 | func TestSetRequestBaseRetargetsAStackedRequest(t *testing.T) { | ||
| 540 | t.Parallel() | ||
| 541 | g := newRequestRepo(t) | ||
| 542 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 543 | r, err := NewRequest("Stacked", "", "keenan", "main", headSHA, now) | ||
| 544 | if err != nil { | ||
| 545 | t.Fatal(err) | ||
| 546 | } | ||
| 547 | if err := CreateRequest(g, r, "keenan"); err != nil { | ||
| 548 | t.Fatal(err) | ||
| 549 | } | ||
| 550 | if _, err := ApproveRequest(g, r.ID, "wilbur", "main", headSHA, now, "wilbur"); err != nil { | ||
| 551 | t.Fatal(err) | ||
| 552 | } | ||
| 553 | |||
| 554 | got, err := SetRequestBase(g, r.ID, "feature/parent", "keenan") | ||
| 555 | if err != nil || got.Base != "feature/parent" { | ||
| 556 | t.Fatalf("retarget = %q, %v; want feature/parent", got.Base, err) | ||
| 557 | } | ||
| 558 | if got.CurrentApprovals() != 0 { | ||
| 559 | t.Fatalf("an approval of the old base still counts: %+v", got.Approvals) | ||
| 560 | } | ||
| 561 | if len(got.Approvals) != 1 || got.Approvals[0].Base != "main" { | ||
| 562 | t.Fatalf("the approval was not left as history: %+v", got.Approvals) | ||
| 563 | } | ||
| 564 | |||
| 565 | // Repeating the same base is not an edit, so the metadata branch does not move. | ||
| 566 | before, err := g.RevParse(Ref) | ||
| 567 | if err != nil { | ||
| 568 | t.Fatal(err) | ||
| 569 | } | ||
| 570 | if _, err := SetRequestBase(g, r.ID, "feature/parent", "keenan"); err != nil { | ||
| 571 | t.Fatal(err) | ||
| 572 | } | ||
| 573 | after, err := g.RevParse(Ref) | ||
| 574 | if err != nil { | ||
| 575 | t.Fatal(err) | ||
| 576 | } | ||
| 577 | if after != before { | ||
| 578 | t.Fatalf("a no-op retarget moved %s: %s -> %s", Ref, before, after) | ||
| 579 | } | ||
| 580 | |||
| 581 | // Closed and merged requests have settled their base. | ||
| 582 | if _, err := SetRequestState(g, r.ID, "closed", "keenan"); err != nil { | ||
| 583 | t.Fatal(err) | ||
| 584 | } | ||
| 585 | if _, err := SetRequestBase(g, r.ID, "main", "keenan"); err == nil { | ||
| 586 | t.Fatal("a closed request's base moved") | ||
| 587 | } | ||
| 588 | if _, err := SetRequestState(g, r.ID, "open", "keenan"); err != nil { | ||
| 589 | t.Fatal(err) | ||
| 590 | } | ||
| 591 | if _, _, err := MarkMerged(g, r.ID, "", headSHA, headSHA, "reviewer", now); err != nil { | ||
| 592 | t.Fatal(err) | ||
| 593 | } | ||
| 594 | if _, err := SetRequestBase(g, r.ID, "main", "keenan"); err == nil { | ||
| 595 | t.Fatal("a merged request's base moved") | ||
| 596 | } | ||
| 597 | } | ||
| 598 | |||
| 599 | // A request opened from a branch follows it: a push moves the head to the new tip, | ||
| 600 | // the approval of the old tip stops counting, and a pinned or closed request is left | ||
| 601 | // alone. A sync that changes nothing does not grow the metadata log. | ||
| 602 | func TestSyncBranchHeadFollowsOpenRequests(t *testing.T) { | ||
| 603 | t.Parallel() | ||
| 604 | g := newRequestRepo(t) | ||
| 605 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 606 | |||
| 607 | mk := func(title, headRef, head string) *Request { | ||
| 608 | r, err := NewRequest(title, "", "keenan", "main", head, now) | ||
| 609 | if err != nil { | ||
| 610 | t.Fatal(err) | ||
| 611 | } | ||
| 612 | r.HeadRef = headRef | ||
| 613 | if err := CreateRequest(g, r, "keenan"); err != nil { | ||
| 614 | t.Fatal(err) | ||
| 615 | } | ||
| 616 | return r | ||
| 617 | } | ||
| 618 | followed := mk("Followed", "feature", headSHA) | ||
| 619 | pinned := mk("Pinned", "", headSHA) | ||
| 620 | closed := mk("Closed", "feature", headSHA) | ||
| 621 | if _, err := SetRequestState(g, closed.ID, "closed", "keenan"); err != nil { | ||
| 622 | t.Fatal(err) | ||
| 623 | } | ||
| 624 | if _, err := ApproveRequest(g, followed.ID, "wilbur", "main", headSHA, now, "wilbur"); err != nil { | ||
| 625 | t.Fatal(err) | ||
| 626 | } | ||
| 627 | |||
| 628 | n, err := SyncBranchHead(g, "feature", sideSHA, "keenan") | ||
| 629 | if err != nil || n != 1 { | ||
| 630 | t.Fatalf("sync = %d, %v; want 1", n, err) | ||
| 631 | } | ||
| 632 | got, err := GetRequest(g, followed.ID) | ||
| 633 | if err != nil { | ||
| 634 | t.Fatal(err) | ||
| 635 | } | ||
| 636 | if got.Head != sideSHA || got.HeadRef != "feature" { | ||
| 637 | t.Fatalf("followed = %s on %q, want %s on feature", got.Head, got.HeadRef, sideSHA) | ||
| 638 | } | ||
| 639 | if got.CurrentApprovals() != 0 { | ||
| 640 | t.Fatalf("the approval of the old head still counts: %+v", got.Approvals) | ||
| 641 | } | ||
| 642 | if len(got.Approvals) != 1 || got.Approvals[0].Head != headSHA { | ||
| 643 | t.Fatalf("the approval was not left as history: %+v", got.Approvals) | ||
| 644 | } | ||
| 645 | for _, want := range []*Request{pinned, closed} { | ||
| 646 | cur, err := GetRequest(g, want.ID) | ||
| 647 | if err != nil { | ||
| 648 | t.Fatal(err) | ||
| 649 | } | ||
| 650 | if cur.Head != headSHA { | ||
| 651 | t.Fatalf("%s moved to %s", cur.Title, cur.Head) | ||
| 652 | } | ||
| 653 | } | ||
| 654 | |||
| 655 | // A ref that already names the head is not a change, so it must not add a commit. | ||
| 656 | before, err := g.RevParse(Ref) | ||
| 657 | if err != nil { | ||
| 658 | t.Fatal(err) | ||
| 659 | } | ||
| 660 | if n, err := SyncBranchHead(g, "feature", sideSHA, "keenan"); err != nil || n != 0 { | ||
| 661 | t.Fatalf("second sync = %d, %v", n, err) | ||
| 662 | } | ||
| 663 | after, err := g.RevParse(Ref) | ||
| 664 | if err != nil { | ||
| 665 | t.Fatal(err) | ||
| 666 | } | ||
| 667 | if after != before { | ||
| 668 | t.Fatalf("a no-op sync moved %s: %s -> %s", Ref, before, after) | ||
| 669 | } | ||
| 670 | if n, err := SyncBranchHead(g, "other", sideSHA, "keenan"); err != nil || n != 0 { | ||
| 671 | t.Fatalf("unrelated branch sync = %d, %v", n, err) | ||
| 672 | } | ||
| 673 | } | ||
| 674 | |||
| 675 | // The head can move between reading a request and approving it, because a push to | ||
| 676 | // the followed branch moves it, and the base can move when a stacked request is | ||
| 677 | // retargeted. The approval must name the target the approver saw, so the writer | ||
| 678 | // refuses a moved base or head instead of approving the new one. | ||
| 679 | func TestApproveRequestRefusesAMovedTarget(t *testing.T) { | ||
| 680 | t.Parallel() | ||
| 681 | g := newRequestRepo(t) | ||
| 682 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 683 | r, err := NewRequest("Land it", "", "keenan", "main", headSHA, now) | ||
| 684 | if err != nil { | ||
| 685 | t.Fatal(err) | ||
| 686 | } | ||
| 687 | r.HeadRef = "feature" | ||
| 688 | if err := CreateRequest(g, r, "keenan"); err != nil { | ||
| 689 | t.Fatal(err) | ||
| 690 | } | ||
| 691 | |||
| 692 | if _, err := ApproveRequest(g, r.ID, "wilbur", "main", sideSHA, now, "wilbur"); err == nil || | ||
| 693 | !strings.Contains(err.Error(), "moved from") || | ||
| 694 | !strings.Contains(err.Error(), "review the new head") { | ||
| 695 | t.Fatalf("a moved head was approved: %v", err) | ||
| 696 | } | ||
| 697 | got, err := GetRequest(g, r.ID) | ||
| 698 | if err != nil { | ||
| 699 | t.Fatal(err) | ||
| 700 | } | ||
| 701 | if len(got.Approvals) != 0 { | ||
| 702 | t.Fatalf("a refused approval was written: %+v", got.Approvals) | ||
| 703 | } | ||
| 704 | |||
| 705 | // Retargeting is the other way the review target can move under an approver. | ||
| 706 | if _, err := SetRequestBase(g, r.ID, "next", "keenan"); err != nil { | ||
| 707 | t.Fatal(err) | ||
| 708 | } | ||
| 709 | if _, err := ApproveRequest(g, r.ID, "wilbur", "main", headSHA, now, "wilbur"); err == nil || | ||
| 710 | !strings.Contains(err.Error(), "moved base from main to next") { | ||
| 711 | t.Fatalf("a moved base was approved: %v", err) | ||
| 712 | } | ||
| 713 | if _, err := SetRequestBase(g, r.ID, "main", "keenan"); err != nil { | ||
| 714 | t.Fatal(err) | ||
| 715 | } | ||
| 716 | if _, err := ApproveRequest(g, r.ID, "wilbur", "main", headSHA, now, "wilbur"); err != nil { | ||
| 717 | t.Fatal(err) | ||
| 718 | } | ||
| 719 | got, err = GetRequest(g, r.ID) | ||
| 720 | if err != nil { | ||
| 721 | t.Fatal(err) | ||
| 722 | } | ||
| 723 | if len(got.Approvals) != 1 || got.Approvals[0].Head != headSHA { | ||
| 724 | t.Fatalf("approval = %+v, want head %s", got.Approvals, headSHA) | ||
| 725 | } | ||
| 726 | } | ||
| 727 | |||
| 728 | // A merged record names what landed. If the followed branch moves after the landing | ||
| 729 | // was prepared, head is frozen to the reviewed commit rather than following the | ||
| 730 | // branch past it. | ||
| 731 | func TestMarkMergedFreezesTheReviewedHead(t *testing.T) { | ||
| 732 | t.Parallel() | ||
| 733 | g := newRequestRepo(t) | ||
| 734 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 735 | r, err := NewRequest("Land it", "", "keenan", "main", headSHA, now) | ||
| 736 | if err != nil { | ||
| 737 | t.Fatal(err) | ||
| 738 | } | ||
| 739 | r.HeadRef = "feature" | ||
| 740 | if err := CreateRequest(g, r, "keenan"); err != nil { | ||
| 741 | t.Fatal(err) | ||
| 742 | } | ||
| 743 | if _, err := SyncBranchHead(g, "feature", sideSHA, "keenan"); err != nil { | ||
| 744 | t.Fatal(err) | ||
| 745 | } | ||
| 746 | got, _, err := MarkMerged(g, r.ID, "", headSHA, headSHA, "reviewer", now) | ||
| 747 | if err != nil { | ||
| 748 | t.Fatal(err) | ||
| 749 | } | ||
| 750 | if got.Head != headSHA || got.MergedHead != headSHA { | ||
| 751 | t.Fatalf("merged record = head %s, merged %s; want both %s", got.Head, got.MergedHead, headSHA) | ||
| 752 | } | ||
| 753 | } | ||
| 754 | |||
| 755 | func newRequestRepo(t *testing.T) *git.Repo { | ||
| 756 | t.Helper() | ||
| 757 | root := t.TempDir() | ||
| 758 | if err := git.InitBare(root, "main"); err != nil { | ||
| 759 | t.Fatal(err) | ||
| 760 | } | ||
| 761 | g := git.Open(root) | ||
| 762 | seed, err := g.CommitTree(mustTree(t, g), "", "seed") | ||
| 763 | if err != nil { | ||
| 764 | t.Fatal(err) | ||
| 765 | } | ||
| 766 | if err := g.UpdateRef(Ref, seed); err != nil { | ||
| 767 | t.Fatal(err) | ||
| 768 | } | ||
| 769 | return g | ||
| 770 | } | ||
| 771 | |||
| 536 | 772 | // A link is often discovered after a request is opened, so it can be set later. The | |
| 537 | 773 | // same rules apply: a full id, an issue that exists, and only while the request can | |
| 538 | 774 | // still land. | |
| @@ -583,7 +819,7 @@ func TestSetRequestClosesEditsAnOpenRequest(t *testing.T) { | |||
| 583 | 819 | } | |
| 584 | 820 | } | |
| 585 | 821 | // Once merged, the list is a record of what happened, not a setting. | |
| 586 | if _, _, err := MarkMerged(g, req.ID, sideSHA, headSHA, "reviewer", now); err != nil { | ||
| 822 | if _, _, err := MarkMerged(g, req.ID, sideSHA, headSHA, headSHA, "reviewer", now); err != nil { | ||
| 587 | 823 | t.Fatal(err) | |
| 588 | 824 | } | |
| 589 | 825 | if _, err := SetRequestCloses(g, req.ID, nil, "keenan"); err == nil { | |