Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 {