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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

394 commit(s) on main

Default the run verbs to origin Keenan Nemetz · 10m ago 2e6aa96e
history blame raw 19.0 KiB
1package 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
9import (
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "rodent.software/vole/core/git"
16 "rodent.software/vole/forge/access"
17 "rodent.software/vole/forge/repo"
18 "rodent.software/vole/metadata"
19)
20
21type fixture struct {
22 t *testing.T
23 g *git.Repo
24 m *repo.Meta
25 meta string // the metadata branch's first commit
26 // acls is the store, so a test can decide which accounts the roster still
27 // considers active.
28 acls *access.Store
29}
30
31func newFixture(t *testing.T) *fixture {
32 t.Helper()
33 root := t.TempDir()
34 acls, err := access.Open(filepath.Join(root, "users.db"))
35 if err != nil {
36 t.Fatal(err)
37 }
38 acls.AuditPath = filepath.Join(root, "audit.log")
39 t.Cleanup(func() { acls.Close() })
40 st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls)
41 m, err := st.Create("demo", "owner", "")
42 if err != nil {
43 t.Fatal(err)
44 }
45 // Every landing test approves as "reviewer", and an approval only counts from an
46 // account that currently maintains the repository, so the fixture grants it the
47 // way an operator would.
48 if _, err := m.UpdateACL("test", func(a *access.ACL) error {
49 return a.SetEntry("reviewer", access.Maintain.String())
50 }); err != nil {
51 t.Fatal(err)
52 }
53 g := m.Git()
54 // The metadata branch has to exist before a request can be written to it.
55 tree, err := g.MkTree(nil)
56 if err != nil {
57 t.Fatal(err)
58 }
59 seed, err := g.CommitTree(tree, "", "start the metadata branch")
60 if err != nil {
61 t.Fatal(err)
62 }
63 if err := g.UpdateRef(metadata.Ref, seed); err != nil {
64 t.Fatal(err)
65 }
66 return &fixture{t: t, g: g, m: m, meta: seed, acls: acls}
67}
68
69// rules rewrites the repository's ACL, which is where protection and the approval
70// count live.
71func (f *fixture) rules(mut func(*access.ACL)) {
72 f.t.Helper()
73 a, err := f.m.ACL()
74 if err != nil {
75 f.t.Fatal(err)
76 }
77 mut(a)
78 if err := f.m.SetACL(a, "test"); err != nil {
79 f.t.Fatal(err)
80 }
81}
82
83// commit writes one file on top of parent and returns the commit.
84func (f *fixture) commit(parent, name, content, msg string) string {
85 f.t.Helper()
86 blob, err := f.g.HashObject([]byte(content))
87 if err != nil {
88 f.t.Fatal(err)
89 }
90 tree, err := f.g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: name}})
91 if err != nil {
92 f.t.Fatal(err)
93 }
94 c, err := f.g.CommitTree(tree, parent, msg)
95 if err != nil {
96 f.t.Fatal(err)
97 }
98 return c
99}
100
101// review opens a request for head against main and has reviewer approve it.
102func (f *fixture) review(author, head, reviewer string) string {
103 f.t.Helper()
104 return f.reviewWith(author, head, reviewer, nil)
105}
106
107func (f *fixture) reviewWith(author, head, reviewer string, closes []string) string {
108 f.t.Helper()
109 now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
110 req, err := metadata.NewRequest("Land it", "why", author, "main", head, now)
111 if err != nil {
112 f.t.Fatal(err)
113 }
114 req.Closes = closes
115 if err := metadata.CreateRequest(f.g, req, author); err != nil {
116 f.t.Fatal(err)
117 }
118 if reviewer != "" {
119 if _, err := metadata.ApproveRequest(f.g, req.ID, reviewer, req.Base, req.Head, now.Add(time.Minute), reviewer); err != nil {
120 f.t.Fatal(err)
121 }
122 }
123 return req.ID
124}
125
126func (f *fixture) gate(actor string, role access.Role) Gate {
127 a, err := f.m.ACL()
128 if err != nil {
129 f.t.Fatal(err)
130 }
131 return Gate{Repo: f.g, Name: "demo", ACL: a, Actor: actor, Role: role}
132}
133
134// protectMain makes main review-gated with a given number of required approvals and
135// no check requirement, because a fixture has no run queue.
136func (f *fixture) protectMain(approvals int) {
137 f.rules(func(a *access.ACL) {
138 a.Protected = []string{"refs/heads/main"}
139 n := approvals
140 a.ApprovalsRequired = &n
141 no := false
142 a.ChecksRequired = &no
143 })
144}
145
146func TestLandFastForwardsAnApprovedRequest(t *testing.T) {
147 t.Parallel()
148 f := newFixture(t)
149 base := f.commit("", "readme.md", "v1\n", "base")
150 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
151 t.Fatal(err)
152 }
153 head := f.commit(base, "readme.md", "v2\n", "change")
154 if err := f.g.UpdateRef("refs/heads/topic", head); err != nil {
155 t.Fatal(err)
156 }
157 f.protectMain(1)
158 id := f.review("keenan", head, "reviewer")
159
160 res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
161 if err != nil {
162 t.Fatalf("land: %v", err)
163 }
164 if !res.FastForward || res.NewTip != head {
165 t.Fatalf("landed %s fast-forward=%t, want the reviewed head %s", res.NewTip, res.FastForward, head)
166 }
167 if tip, _ := f.g.RevParse("refs/heads/main"); tip != head {
168 t.Fatalf("main = %s, want %s", tip, head)
169 }
170 got, err := metadata.GetRequest(f.g, id)
171 if err != nil {
172 t.Fatal(err)
173 }
174 if got.State != "merged" {
175 t.Fatalf("state = %q, want merged", got.State)
176 }
177 if got.MergedHead != head || got.MergedBy != "reviewer" || got.MergedAt.IsZero() {
178 t.Fatalf("merged record = %q by %q at %v, want head %s by reviewer",
179 got.MergedHead, got.MergedBy, got.MergedAt, head)
180 }
181 // The base tip before the landing is the "before" side of what the merge
182 // changed: once the head is in main, it is the only record of the starting point.
183 if got.MergedBase != base {
184 t.Fatalf("merged_base = %s, want the base tip %s", got.MergedBase, base)
185 }
186}
187
188func TestLandRefusesWithoutAnApproval(t *testing.T) {
189 t.Parallel()
190 f := newFixture(t)
191 base := f.commit("", "readme.md", "v1\n", "base")
192 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
193 t.Fatal(err)
194 }
195 head := f.commit(base, "readme.md", "v2\n", "change")
196 f.protectMain(1)
197 id := f.review("keenan", head, "") // no approval
198
199 _, err := Land(f.gate("writer", access.Maintain), id, "writer", time.Now().UTC())
200 if err == nil {
201 t.Fatal("landed without an approval")
202 }
203 if !strings.Contains(err.Error(), "needs 1 approval") {
204 t.Fatalf("refusal = %q, want it to name the missing approval", err)
205 }
206 if tip, _ := f.g.RevParse("refs/heads/main"); tip != base {
207 t.Fatalf("main moved to %s despite the refusal", tip)
208 }
209 if got, _ := metadata.GetRequest(f.g, id); got.State != "open" {
210 t.Fatalf("state = %q, want open after a refused landing", got.State)
211 }
212}
213
214func TestLandWritesAMergeCommitWhenTheBaseMoved(t *testing.T) {
215 t.Parallel()
216 f := newFixture(t)
217 base := f.commit("", "readme.md", "v1\n", "base")
218 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
219 t.Fatal(err)
220 }
221 head := f.commit(base, "topic.md", "topic\n", "change on the branch")
222 f.protectMain(1)
223 id := f.review("keenan", head, "reviewer")
224 // Somebody else lands a different file first, so the approved head is no longer a
225 // fast-forward. The approval still names that head, and the merge commit keeps it
226 // as a parent.
227 moved := f.commit(base, "other.md", "other\n", "landed first")
228 if err := f.g.UpdateRef("refs/heads/main", moved); err != nil {
229 t.Fatal(err)
230 }
231
232 res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
233 if err != nil {
234 t.Fatalf("land: %v", err)
235 }
236 if !res.MergeCommit || res.NewTip == head {
237 t.Fatalf("landed %s merge-commit=%t, want a merge commit", res.NewTip, res.MergeCommit)
238 }
239 parents, err := f.g.CommitParents(res.NewTip)
240 if err != nil {
241 t.Fatal(err)
242 }
243 if len(parents) != 2 || parents[0] != moved || parents[1] != head {
244 t.Fatalf("parents = %v, want [%s %s]", parents, moved, head)
245 }
246 got, _ := metadata.GetRequest(f.g, id)
247 if got.MergedHead != res.NewTip {
248 t.Fatalf("merged_head = %s, want the merge commit %s", got.MergedHead, res.NewTip)
249 }
250 if got.MergedBase != moved {
251 t.Fatalf("merged_base = %s, want the base tip %s the merge started from", got.MergedBase, moved)
252 }
253}
254
255func TestLandRefusesAConflict(t *testing.T) {
256 t.Parallel()
257 f := newFixture(t)
258 base := f.commit("", "readme.md", "v1\n", "base")
259 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
260 t.Fatal(err)
261 }
262 head := f.commit(base, "readme.md", "branch\n", "branch edit")
263 f.protectMain(1)
264 id := f.review("keenan", head, "reviewer")
265 moved := f.commit(base, "readme.md", "base edit\n", "base edit")
266 if err := f.g.UpdateRef("refs/heads/main", moved); err != nil {
267 t.Fatal(err)
268 }
269
270 _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
271 if err == nil {
272 t.Fatal("landed a conflicting change")
273 }
274 if !strings.Contains(err.Error(), "readme.md") {
275 t.Fatalf("refusal = %q, want it to name the conflicting path", err)
276 }
277 if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved {
278 t.Fatalf("main = %s, want the untouched %s", tip, moved)
279 }
280}
281
282func TestLandTwiceMarksOnceAndKeepsTheHead(t *testing.T) {
283 t.Parallel()
284 f := newFixture(t)
285 base := f.commit("", "readme.md", "v1\n", "base")
286 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
287 t.Fatal(err)
288 }
289 head := f.commit(base, "readme.md", "v2\n", "change")
290 f.protectMain(1)
291 id := f.review("keenan", head, "reviewer")
292
293 if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err != nil {
294 t.Fatalf("first land: %v", err)
295 }
296 res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
297 if err != nil {
298 t.Fatalf("second land: %v", err)
299 }
300 if !res.AlreadyLanded {
301 t.Fatal("second landing did not report the head as already landed")
302 }
303 got, _ := metadata.GetRequest(f.g, id)
304 if got.State != "merged" || got.MergedHead != head {
305 t.Fatalf("record = %q/%q, want merged at %s", got.State, got.MergedHead, head)
306 }
307 // A retry has no base tip of its own to name, so it keeps the one the first
308 // landing recorded instead of overwriting it with the moved tip.
309 if got.MergedBase != base {
310 t.Fatalf("merged_base = %s, want the retained %s", got.MergedBase, base)
311 }
312}
313
314func TestLandRefusesAClosedRequest(t *testing.T) {
315 t.Parallel()
316 f := newFixture(t)
317 base := f.commit("", "readme.md", "v1\n", "base")
318 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
319 t.Fatal(err)
320 }
321 head := f.commit(base, "readme.md", "v2\n", "change")
322 f.protectMain(1)
323 id := f.review("keenan", head, "reviewer")
324 if _, err := metadata.SetRequestState(f.g, id, "closed", "keenan"); err != nil {
325 t.Fatal(err)
326 }
327 if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err == nil {
328 t.Fatal("landed a closed request")
329 }
330}
331
332// Prepare is what the page asks before drawing a merge control, so it has to report a
333// conflict rather than only refusing one: the control is drawn from the plan, and the
334// paths are what the Review section shows.
335func TestPrepareReportsConflictsWithoutMovingAnything(t *testing.T) {
336 t.Parallel()
337 f := newFixture(t)
338 base := f.commit("", "readme.md", "v1\n", "base")
339 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
340 t.Fatal(err)
341 }
342 head := f.commit(base, "readme.md", "branch\n", "branch edit")
343 f.protectMain(1)
344 id := f.review("keenan", head, "reviewer")
345 moved := f.commit(base, "readme.md", "base edit\n", "base edit")
346 if err := f.g.UpdateRef("refs/heads/main", moved); err != nil {
347 t.Fatal(err)
348 }
349
350 plan, err := Prepare(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
351 if err != nil {
352 t.Fatalf("prepare: %v", err)
353 }
354 if len(plan.Conflicts) == 0 || plan.Conflicts[0] != "readme.md" {
355 t.Fatalf("conflicts = %v, want [readme.md]", plan.Conflicts)
356 }
357 if plan.NewTip != "" {
358 t.Fatalf("NewTip = %s, want empty when the plan cannot land", plan.NewTip)
359 }
360 if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved {
361 t.Fatalf("main = %s, want the untouched %s", tip, moved)
362 }
363}
364
365// A clean non-fast-forward lands with a merge commit, and the plan says so before
366// anything moves — the same answer the write then carries out.
367func TestPrepareDescribesAMergeCommit(t *testing.T) {
368 t.Parallel()
369 f := newFixture(t)
370 base := f.commit("", "readme.md", "v1\n", "base")
371 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
372 t.Fatal(err)
373 }
374 head := f.commit(base, "topic.md", "topic\n", "branch")
375 f.protectMain(1)
376 id := f.review("keenan", head, "reviewer")
377 moved := f.commit(base, "other.md", "other\n", "landed first")
378 if err := f.g.UpdateRef("refs/heads/main", moved); err != nil {
379 t.Fatal(err)
380 }
381
382 plan, err := Prepare(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
383 if err != nil {
384 t.Fatalf("prepare: %v", err)
385 }
386 if !plan.MergeCommit || len(plan.Conflicts) > 0 || plan.NewTip == "" {
387 t.Fatalf("plan = merge %t conflicts %v tip %q, want a merge commit", plan.MergeCommit, plan.Conflicts, plan.NewTip)
388 }
389 parents, err := f.g.CommitParents(plan.NewTip)
390 if err != nil {
391 t.Fatal(err)
392 }
393 if len(parents) != 2 || parents[0] != moved || parents[1] != head {
394 t.Fatalf("parents = %v, want [%s %s]", parents, moved, head)
395 }
396 // Preparing wrote an unreferenced commit and moved nothing.
397 if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved {
398 t.Fatalf("main = %s, want the untouched %s", tip, moved)
399 }
400 // The plan the write carries out is the same one: same parents and shape. The
401 // commit id may differ because a merge commit's timestamp has second resolution,
402 // so a Prepare and a Land that straddle a second boundary write different objects
403 // for the same merge.
404 res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
405 if err != nil {
406 t.Fatalf("land: %v", err)
407 }
408 if !res.MergeCommit {
409 t.Fatalf("landed %s, want a merge commit", res.NewTip)
410 }
411 landedParents, err := f.g.CommitParents(res.NewTip)
412 if err != nil {
413 t.Fatal(err)
414 }
415 if len(landedParents) != 2 || landedParents[0] != moved || landedParents[1] != head {
416 t.Fatalf("landed parents = %v, want [%s %s]", landedParents, moved, head)
417 }
418}
419
420// Landing a request closes the issues it says it closes, in the same commit that
421// marks it merged. The point of the link is that nobody has to remember.
422func TestLandClosesTheIssuesTheRequestNames(t *testing.T) {
423 t.Parallel()
424 f := newFixture(t)
425 now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
426 closing, err := metadata.NewIssue("Blocked by the workflow", "why", "keenan", nil, nil, now)
427 if err != nil {
428 t.Fatal(err)
429 }
430 if err := metadata.CreateIssue(f.g, closing, "keenan"); err != nil {
431 t.Fatal(err)
432 }
433 other, err := metadata.NewIssue("Unrelated", "why", "keenan", nil, nil, now)
434 if err != nil {
435 t.Fatal(err)
436 }
437 if err := metadata.CreateIssue(f.g, other, "keenan"); err != nil {
438 t.Fatal(err)
439 }
440
441 base := f.commit("", "readme.md", "v1\n", "base")
442 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
443 t.Fatal(err)
444 }
445 head := f.commit(base, "readme.md", "v2\n", "change")
446 f.protectMain(1)
447 id := f.reviewWith("keenan", head, "reviewer", []string{closing.ID})
448
449 res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC())
450 if err != nil {
451 t.Fatalf("land: %v", err)
452 }
453 if len(res.ClosedIssues) != 1 || res.ClosedIssues[0] != closing.ID {
454 t.Fatalf("closed = %v, want [%s]", res.ClosedIssues, closing.ID)
455 }
456 issues, err := metadata.LoadIssues(f.g, metadata.Ref)
457 if err != nil {
458 t.Fatal(err)
459 }
460 state := map[string]string{}
461 for _, iss := range issues {
462 state[iss.ID] = iss.State
463 }
464 if state[closing.ID] != "closed" {
465 t.Fatalf("linked issue = %q, want closed", state[closing.ID])
466 }
467 if state[other.ID] != "open" {
468 t.Fatalf("unrelated issue = %q, want open", state[other.ID])
469 }
470}
471
472// An approval is a statement by somebody who is answerable for it now. A
473// maintainer who has been demoted cannot land yesterday's review, and the page
474// stops counting it at the same moment.
475func TestLandRefusesAnApprovalFromADemotedMaintainer(t *testing.T) {
476 t.Parallel()
477 f := newFixture(t)
478 base := f.commit("", "readme.md", "v1\n", "base")
479 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
480 t.Fatal(err)
481 }
482 head := f.commit(base, "readme.md", "v2\n", "change")
483 if err := f.g.UpdateRef("refs/heads/topic", head); err != nil {
484 t.Fatal(err)
485 }
486 f.protectMain(1)
487 id := f.review("keenan", head, "reviewer")
488
489 // The demotion happens after the approval was written, which is the case the
490 // file alone cannot see.
491 if _, err := f.m.UpdateACL("root", func(a *access.ACL) error {
492 return a.SetEntry("reviewer", access.Contribute.String())
493 }); err != nil {
494 t.Fatal(err)
495 }
496 if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err == nil {
497 t.Fatal("a demoted maintainer's approval still landed a change")
498 }
499}
500
501// Retirement is the other way standing lapses: the account is gone, the approval
502// file is still on the branch.
503func TestLandRefusesAnApprovalFromARetiredAccount(t *testing.T) {
504 t.Parallel()
505 f := newFixture(t)
506 base := f.commit("", "readme.md", "v1\n", "base")
507 if err := f.g.UpdateRef("refs/heads/main", base); err != nil {
508 t.Fatal(err)
509 }
510 head := f.commit(base, "readme.md", "v2\n", "change")
511 if err := f.g.UpdateRef("refs/heads/topic", head); err != nil {
512 t.Fatal(err)
513 }
514 f.protectMain(1)
515 id := f.review("keenan", head, "reviewer")
516
517 f.acls.Active = func(name string) bool { return name != "reviewer" }
518 if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err == nil {
519 t.Fatal("a retired account's approval still landed a change")
520 }
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.
527func 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}
581