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.

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 }