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