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.
Land a change request on the server instead of by pushing
38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 14d ago · parent 536f08b9
Land a change request on the server instead of by pushing A change request could only be landed by merging locally and pushing the base branch: the web page was a review surface with no way to finish, and nothing recorded that the request had landed. `state: merged` was already accepted by the request reader and rendered by the activity feed, but nothing ever wrote it, so a merged request and an abandoned one looked identical. The gate that decides a landing lives in the pre-receive hook, and the hook only sees pushes. A merge performed inside the server moves the ref without the hook, so a server-side merge either re-runs that rule or becomes the easiest way to land an unapproved change. It now re-runs it: the approval and check rule moved to forge/landing, and both the hook and the new landing operation call the same Check. Landing is fast-forward when the base is an ancestor of the reviewed head, so the commit that passed CI is the commit that lands; otherwise a two-parent merge commit, which is the only other shape the gate accepts. Conflicts are refused with the conflicting paths, never resolved. The ref moves by compare-and-swap, so a concurrent push loses cleanly and the landing retries; the request is marked merged after the ref moves, which makes a retry after a crash finish the marking instead of failing. The request now records merged_head, merged_by and merged_at, and the reader refuses a merged request that names no commit, and refuses those fields on a request that is not merged. "merged" is no longer settable through the generic state writer: it has to travel with the commit. The browser gets a Merge button that preflights the same gate, so it only appears when the landing would succeed, and the page shows what landed, who landed it, and when. `vole change merge` follows in its own change: the CLI has no access to the ACL or the run queue, so it needs a verb on the SSH channel, and that transport is worth reviewing on its own. Closes the landing half of #01M5XF4XWHVMMCQEYENA8X32GK.
forge/landing/land_test.go +299
| @@ -0,0 +1,299 @@ | |||
| 1 | package landing | ||
| 2 | |||
| 3 | // The landing operation moves a protected branch without the pre-receive hook, so | ||
| 4 | // these tests are the ones that hold it to the same rule a push meets: no approval, | ||
| 5 | // no landing; a moved base gets a merge commit rather than a lost approval; a | ||
| 6 | // conflict is a refusal and not a resolved mess; and the request says "merged" only | ||
| 7 | // when a commit backs it. | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "path/filepath" | ||
| 11 | "strings" | ||
| 12 | "testing" | ||
| 13 | "time" | ||
| 14 | |||
| 15 | "github.com/rodent-software/vole/core/git" | ||
| 16 | "github.com/rodent-software/vole/forge/access" | ||
| 17 | "github.com/rodent-software/vole/forge/repo" | ||
| 18 | "github.com/rodent-software/vole/metadata" | ||
| 19 | ) | ||
| 20 | |||
| 21 | type fixture struct { | ||
| 22 | t *testing.T | ||
| 23 | g *git.Repo | ||
| 24 | m *repo.Meta | ||
| 25 | meta string // the metadata branch's first commit | ||
| 26 | } | ||
| 27 | |||
| 28 | func newFixture(t *testing.T) *fixture { | ||
| 29 | t.Helper() | ||
| 30 | root := t.TempDir() | ||
| 31 | acls, err := access.Open(filepath.Join(root, "users.db")) | ||
| 32 | if err != nil { | ||
| 33 | t.Fatal(err) | ||
| 34 | } | ||
| 35 | t.Cleanup(func() { acls.Close() }) | ||
| 36 | st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls) | ||
| 37 | m, err := st.Create("demo", "owner", "") | ||
| 38 | if err != nil { | ||
| 39 | t.Fatal(err) | ||
| 40 | } | ||
| 41 | g := m.Git() | ||
| 42 | // The metadata branch has to exist before a request can be written to it. | ||
| 43 | tree, err := g.MkTree(nil) | ||
| 44 | if err != nil { | ||
| 45 | t.Fatal(err) | ||
| 46 | } | ||
| 47 | seed, err := g.CommitTree(tree, "", "start the metadata branch") | ||
| 48 | if err != nil { | ||
| 49 | t.Fatal(err) | ||
| 50 | } | ||
| 51 | if err := g.UpdateRef(metadata.Ref, seed); err != nil { | ||
| 52 | t.Fatal(err) | ||
| 53 | } | ||
| 54 | return &fixture{t: t, g: g, m: m, meta: seed} | ||
| 55 | } | ||
| 56 | |||
| 57 | // rules rewrites the repository's ACL, which is where protection and the approval | ||
| 58 | // count live. | ||
| 59 | func (f *fixture) rules(mut func(*access.ACL)) { | ||
| 60 | f.t.Helper() | ||
| 61 | a, err := f.m.ACL() | ||
| 62 | if err != nil { | ||
| 63 | f.t.Fatal(err) | ||
| 64 | } | ||
| 65 | mut(a) | ||
| 66 | if err := f.m.SetACL(a, "test"); err != nil { | ||
| 67 | f.t.Fatal(err) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | // commit writes one file on top of parent and returns the commit. | ||
| 72 | func (f *fixture) commit(parent, name, content, msg string) string { | ||
| 73 | f.t.Helper() | ||
| 74 | blob, err := f.g.HashObject([]byte(content)) | ||
| 75 | if err != nil { | ||
| 76 | f.t.Fatal(err) | ||
| 77 | } | ||
| 78 | tree, err := f.g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: name}}) | ||
| 79 | if err != nil { | ||
| 80 | f.t.Fatal(err) | ||
| 81 | } | ||
| 82 | c, err := f.g.CommitTree(tree, parent, msg) | ||
| 83 | if err != nil { | ||
| 84 | f.t.Fatal(err) | ||
| 85 | } | ||
| 86 | return c | ||
| 87 | } | ||
| 88 | |||
| 89 | // review opens a request for head against main and has reviewer approve it. | ||
| 90 | func (f *fixture) review(author, head, reviewer string) string { | ||
| 91 | f.t.Helper() | ||
| 92 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 93 | req, err := metadata.NewRequest("Land it", "why", author, "main", head, now) | ||
| 94 | if err != nil { | ||
| 95 | f.t.Fatal(err) | ||
| 96 | } | ||
| 97 | if err := metadata.CreateRequest(f.g, req, author); err != nil { | ||
| 98 | f.t.Fatal(err) | ||
| 99 | } | ||
| 100 | if reviewer != "" { | ||
| 101 | if _, err := metadata.ApproveRequest(f.g, req.ID, reviewer, now.Add(time.Minute), reviewer); err != nil { | ||
| 102 | f.t.Fatal(err) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | return req.ID | ||
| 106 | } | ||
| 107 | |||
| 108 | func (f *fixture) gate(actor string, role access.Role) Gate { | ||
| 109 | a, err := f.m.ACL() | ||
| 110 | if err != nil { | ||
| 111 | f.t.Fatal(err) | ||
| 112 | } | ||
| 113 | return Gate{Repo: f.g, Name: "demo", ACL: a, Actor: actor, Role: role} | ||
| 114 | } | ||
| 115 | |||
| 116 | // protectMain makes main review-gated with a given number of required approvals and | ||
| 117 | // no check requirement, because a fixture has no run queue. | ||
| 118 | func (f *fixture) protectMain(approvals int) { | ||
| 119 | f.rules(func(a *access.ACL) { | ||
| 120 | a.Protected = []string{"refs/heads/main"} | ||
| 121 | n := approvals | ||
| 122 | a.ApprovalsRequired = &n | ||
| 123 | no := false | ||
| 124 | a.ChecksRequired = &no | ||
| 125 | }) | ||
| 126 | } | ||
| 127 | |||
| 128 | func TestLandFastForwardsAnApprovedRequest(t *testing.T) { | ||
| 129 | t.Parallel() | ||
| 130 | f := newFixture(t) | ||
| 131 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 132 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 133 | t.Fatal(err) | ||
| 134 | } | ||
| 135 | head := f.commit(base, "readme.md", "v2\n", "change") | ||
| 136 | if err := f.g.UpdateRef("refs/heads/topic", head); err != nil { | ||
| 137 | t.Fatal(err) | ||
| 138 | } | ||
| 139 | f.protectMain(1) | ||
| 140 | id := f.review("keenan", head, "reviewer") | ||
| 141 | |||
| 142 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) | ||
| 143 | if err != nil { | ||
| 144 | t.Fatalf("land: %v", err) | ||
| 145 | } | ||
| 146 | if !res.FastForward || res.NewTip != head { | ||
| 147 | t.Fatalf("landed %s fast-forward=%t, want the reviewed head %s", res.NewTip, res.FastForward, head) | ||
| 148 | } | ||
| 149 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != head { | ||
| 150 | t.Fatalf("main = %s, want %s", tip, head) | ||
| 151 | } | ||
| 152 | got, err := metadata.GetRequest(f.g, id) | ||
| 153 | if err != nil { | ||
| 154 | t.Fatal(err) | ||
| 155 | } | ||
| 156 | if got.State != "merged" { | ||
| 157 | t.Fatalf("state = %q, want merged", got.State) | ||
| 158 | } | ||
| 159 | if got.MergedHead != head || got.MergedBy != "reviewer" || got.MergedAt.IsZero() { | ||
| 160 | t.Fatalf("merged record = %q by %q at %v, want head %s by reviewer", | ||
| 161 | got.MergedHead, got.MergedBy, got.MergedAt, head) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | func TestLandRefusesWithoutAnApproval(t *testing.T) { | ||
| 166 | t.Parallel() | ||
| 167 | f := newFixture(t) | ||
| 168 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 169 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 170 | t.Fatal(err) | ||
| 171 | } | ||
| 172 | head := f.commit(base, "readme.md", "v2\n", "change") | ||
| 173 | f.protectMain(1) | ||
| 174 | id := f.review("keenan", head, "") // no approval | ||
| 175 | |||
| 176 | _, err := Land(f.gate("writer", access.Maintain), id, "writer", time.Now().UTC()) | ||
| 177 | if err == nil { | ||
| 178 | t.Fatal("landed without an approval") | ||
| 179 | } | ||
| 180 | if !strings.Contains(err.Error(), "needs 1 approval") { | ||
| 181 | t.Fatalf("refusal = %q, want it to name the missing approval", err) | ||
| 182 | } | ||
| 183 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != base { | ||
| 184 | t.Fatalf("main moved to %s despite the refusal", tip) | ||
| 185 | } | ||
| 186 | if got, _ := metadata.GetRequest(f.g, id); got.State != "open" { | ||
| 187 | t.Fatalf("state = %q, want open after a refused landing", got.State) | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | func TestLandWritesAMergeCommitWhenTheBaseMoved(t *testing.T) { | ||
| 192 | t.Parallel() | ||
| 193 | f := newFixture(t) | ||
| 194 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 195 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 196 | t.Fatal(err) | ||
| 197 | } | ||
| 198 | head := f.commit(base, "topic.md", "topic\n", "change on the branch") | ||
| 199 | f.protectMain(1) | ||
| 200 | id := f.review("keenan", head, "reviewer") | ||
| 201 | // Somebody else lands a different file first, so the approved head is no longer a | ||
| 202 | // fast-forward. The approval still names that head, and the merge commit keeps it | ||
| 203 | // as a parent. | ||
| 204 | moved := f.commit(base, "other.md", "other\n", "landed first") | ||
| 205 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { | ||
| 206 | t.Fatal(err) | ||
| 207 | } | ||
| 208 | |||
| 209 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) | ||
| 210 | if err != nil { | ||
| 211 | t.Fatalf("land: %v", err) | ||
| 212 | } | ||
| 213 | if !res.MergeCommit || res.NewTip == head { | ||
| 214 | t.Fatalf("landed %s merge-commit=%t, want a merge commit", res.NewTip, res.MergeCommit) | ||
| 215 | } | ||
| 216 | parents, err := f.g.CommitParents(res.NewTip) | ||
| 217 | if err != nil { | ||
| 218 | t.Fatal(err) | ||
| 219 | } | ||
| 220 | if len(parents) != 2 || parents[0] != moved || parents[1] != head { | ||
| 221 | t.Fatalf("parents = %v, want [%s %s]", parents, moved, head) | ||
| 222 | } | ||
| 223 | got, _ := metadata.GetRequest(f.g, id) | ||
| 224 | if got.MergedHead != res.NewTip { | ||
| 225 | t.Fatalf("merged_head = %s, want the merge commit %s", got.MergedHead, res.NewTip) | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 229 | func TestLandRefusesAConflict(t *testing.T) { | ||
| 230 | t.Parallel() | ||
| 231 | f := newFixture(t) | ||
| 232 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 233 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 234 | t.Fatal(err) | ||
| 235 | } | ||
| 236 | head := f.commit(base, "readme.md", "branch\n", "branch edit") | ||
| 237 | f.protectMain(1) | ||
| 238 | id := f.review("keenan", head, "reviewer") | ||
| 239 | moved := f.commit(base, "readme.md", "base edit\n", "base edit") | ||
| 240 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { | ||
| 241 | t.Fatal(err) | ||
| 242 | } | ||
| 243 | |||
| 244 | _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) | ||
| 245 | if err == nil { | ||
| 246 | t.Fatal("landed a conflicting change") | ||
| 247 | } | ||
| 248 | if !strings.Contains(err.Error(), "readme.md") { | ||
| 249 | t.Fatalf("refusal = %q, want it to name the conflicting path", err) | ||
| 250 | } | ||
| 251 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved { | ||
| 252 | t.Fatalf("main = %s, want the untouched %s", tip, moved) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | func TestLandTwiceMarksOnceAndKeepsTheHead(t *testing.T) { | ||
| 257 | t.Parallel() | ||
| 258 | f := newFixture(t) | ||
| 259 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 260 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 261 | t.Fatal(err) | ||
| 262 | } | ||
| 263 | head := f.commit(base, "readme.md", "v2\n", "change") | ||
| 264 | f.protectMain(1) | ||
| 265 | id := f.review("keenan", head, "reviewer") | ||
| 266 | |||
| 267 | if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err != nil { | ||
| 268 | t.Fatalf("first land: %v", err) | ||
| 269 | } | ||
| 270 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) | ||
| 271 | if err != nil { | ||
| 272 | t.Fatalf("second land: %v", err) | ||
| 273 | } | ||
| 274 | if !res.AlreadyLanded { | ||
| 275 | t.Fatal("second landing did not report the head as already landed") | ||
| 276 | } | ||
| 277 | got, _ := metadata.GetRequest(f.g, id) | ||
| 278 | if got.State != "merged" || got.MergedHead != head { | ||
| 279 | t.Fatalf("record = %q/%q, want merged at %s", got.State, got.MergedHead, head) | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | func TestLandRefusesAClosedRequest(t *testing.T) { | ||
| 284 | t.Parallel() | ||
| 285 | f := newFixture(t) | ||
| 286 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 287 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 288 | t.Fatal(err) | ||
| 289 | } | ||
| 290 | head := f.commit(base, "readme.md", "v2\n", "change") | ||
| 291 | f.protectMain(1) | ||
| 292 | id := f.review("keenan", head, "reviewer") | ||
| 293 | if _, err := metadata.SetRequestState(f.g, id, "closed", "keenan"); err != nil { | ||
| 294 | t.Fatal(err) | ||
| 295 | } | ||
| 296 | if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err == nil { | ||
| 297 | t.Fatal("landed a closed request") | ||
| 298 | } | ||
| 299 | } | ||