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.

Move followed requests when a landing moves the branch

e535ca55547e47705590df49b2ea571dc7b3fa61 · Keenan Nemetz · 5d ago · parent a32a859a

Move followed requests when a landing moves the branch

A landing moves its base branch inside the server, so post-receive never runs
and the open requests that follow that branch keep the pre-merge tip. A stacked
request then reviews code the branch no longer has: its page says "branch
moved", its checks are looked up for the old commit, and any approval it holds
names a commit that is no longer the head.

MarkMerged now moves those followers in the same metadata commit that marks the
request merged and closes its issues, so the landing and its followers cannot
disagree if the write is interrupted. The follower rule is one function shared
with SyncBranchHead instead of two copies.

Closes #196

forge/landing/land_test.go +59

@@ -519,3 +519,62 @@ func TestLandRefusesAnApprovalFromARetiredAccount(t *testing.T) {
519 519 t.Fatal("a retired account's approval still landed a change")
520 520 }
521 521 }
522
523 // TestLandMovesTheRequestsThatFollowTheBase is the stacked-workflow rule for a
524 // landing: moving a base branch without post-receive still has to move the open
525 // requests that follow it, or the next change up the stack keeps reviewing the
526 // pre-merge tip.
527 func TestLandMovesTheRequestsThatFollowTheBase(t *testing.T) {
528 t.Parallel()
529 f := newFixture(t)
530 base := f.commit("", "readme.md", "v1\n", "base")
531 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
532 t.Fatal(err)
533 }
534 lower := f.commit(base, "readme.md", "v2\n", "stack")
535 if err := f.g.UpdateRef("refs/heads/stack", lower); err != nil {
536 t.Fatal(err)
537 }
538 upper := f.commit(lower, "readme.md", "v3\n", "upper")
539 if err := f.g.UpdateRef("refs/heads/topic", upper); err != nil {
540 t.Fatal(err)
541 }
542 now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
543
544 // The lower request targets main and follows the stack branch; the upper one
545 // targets the stack branch. Landing the upper moves the branch the lower
546 // follows, without any push to tell it.
547 lowerReq, err := metadata.NewRequest("Lower", "why", "keenan", "main", lower, now)
548 if err != nil {
549 t.Fatal(err)
550 }
551 lowerReq.HeadRef = "stack"
552 if err := metadata.CreateRequest(f.g, lowerReq, "keenan"); err != nil {
553 t.Fatal(err)
554 }
555 upperReq, err := metadata.NewRequest("Upper", "why", "keenan", "stack", upper, now)
556 if err != nil {
557 t.Fatal(err)
558 }
559 if err := metadata.CreateRequest(f.g, upperReq, "keenan"); err != nil {
560 t.Fatal(err)
561 }
562
563 // An admin on an unprotected base needs no approval; the follower is the point.
564 if _, err := Land(f.gate("owner", access.Admin), upperReq.ID, "owner", now); err != nil {
565 t.Fatalf("land: %v", err)
566 }
567 if tip, _ := f.g.RevParse("refs/heads/stack"); tip != upper {
568 t.Fatalf("stack = %s, want %s", tip, upper)
569 }
570 got, err := metadata.GetRequest(f.g, lowerReq.ID)
571 if err != nil {
572 t.Fatal(err)
573 }
574 if got.State != "open" {
575 t.Fatalf("lower state = %q, want open", got.State)
576 }
577 if got.Head != upper {
578 t.Fatalf("lower head = %s, want the merged stack tip %s", got.Head, upper)
579 }
580 }