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.

Land a change request on the server instead of by pushing

38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 13d 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.

metadata/requests_test.go +68

@@ -10,6 +10,8 @@ import (
10 10 "strings"
11 11 "testing"
12 12 "time"
13
14 "github.com/rodent-software/vole/core/git"
13 15 )
14 16
15 17 const (
@@ -179,3 +181,69 @@ func TestRepliesAllowAnyCommentAsParent(t *testing.T) {
179 181 t.Fatalf("orphan reply was rejected: %v", err)
180 182 }
181 183 }
184
185 // A merged request is a record of a landing, so the file has to name the commit
186 // that landed. Without this, "merged" would be a claim no commit backs, and a
187 // reopened request could keep a stale one.
188 func TestMergedStateNamesTheCommitThatLanded(t *testing.T) {
189 t.Parallel()
190 base := map[string]string{
191 RequestsDir + "/" + reqIDA + "/request.md": "",
192 }
193 merged := func(extra string) map[string]string {
194 m := map[string]string{}
195 for k := range base {
196 m[k] = "---\n" +
197 `title: "Land the retry"` + "\n" +
198 "state: merged\n" +
199 "author: keenan\n" +
200 "base: main\n" +
201 "head: " + headSHA + "\n" +
202 "created: 2026-10-22T22:00:00Z\n" +
203 extra +
204 "---\n" +
205 "why this change\n"
206 }
207 return m
208 }
209
210 if _, err := ParseRequests(merged("merged_head: " + sideSHA + "\nmerged_by: wilbur\nmerged_at: 2026-10-23T09:00:00Z\n")); err != nil {
211 t.Fatalf("a merged request with a commit was refused: %v", err)
212 }
213 for name, extra := range map[string]string{
214 "no merged_head": "merged_by: wilbur\nmerged_at: 2026-10-23T09:00:00Z\n",
215 "short merged_head": "merged_head: " + headSHA[:12] + "\nmerged_by: wilbur\nmerged_at: 2026-10-23T09:00:00Z\n",
216 "no merged_by": "merged_head: " + sideSHA + "\nmerged_at: 2026-10-23T09:00:00Z\n",
217 "no merged_at": "merged_head: " + sideSHA + "\nmerged_by: wilbur\n",
218 "merged_at is not TS": "merged_head: " + sideSHA + "\nmerged_by: wilbur\nmerged_at: yesterday\n",
219 } {
220 if _, err := ParseRequests(merged(extra)); err == nil {
221 t.Errorf("a merged request with %s was accepted", name)
222 }
223 }
224 // A request that is not merged may not carry the record either.
225 pending := map[string]string{
226 RequestsDir + "/" + reqIDA + "/request.md": "---\n" +
227 `title: "Land the retry"` + "\n" +
228 "state: open\n" +
229 "author: keenan\n" +
230 "base: main\n" +
231 "head: " + headSHA + "\n" +
232 "created: 2026-10-22T22:00:00Z\n" +
233 "merged_head: " + sideSHA + "\n" +
234 "---\n",
235 }
236 if _, err := ParseRequests(pending); err == nil {
237 t.Error("an open request with a merged_head was accepted")
238 }
239 }
240
241 // "merged" is not settable through the generic state writer: it has to travel with
242 // the commit, so MarkMerged is the only way in.
243 func TestMergedIsNotAGenericState(t *testing.T) {
244 t.Parallel()
245 var g *git.Repo
246 if _, err := SetRequestState(g, reqIDA, "merged", "keenan"); err == nil {
247 t.Fatal("SetRequestState accepted merged")
248 }
249 }