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 | } | ||