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.
Draw the merge control from the landing plan
097b21105b70f57fbf1c119c87ae7a75b3a44ca6 · Keenan Nemetz · 14d ago · parent b14a7819
Draw the merge control from the landing plan The merge button was drawn from the approval and check gate, which never looks at the tree, so a request that conflicted with its base still offered "Merge" while the "What would land" block two sections above said it did not merge cleanly. Clicking it answered with a bare 409 and one sentence: the page, the diff and the conflict paths were all replaced by text. The decision now lives in one place. landing.Prepare works out what a landing would do — fast-forward, merge commit, already landed, or the paths that conflict — and refuses the same things Land refuses. Land prepares and carries out; the page prepares to decide whether to draw the control; and the refusal path re-renders the request page with the message beside the conflict block instead of answering with plain text. A conflict is reported in the plan rather than as an error, because it is an outcome the page can style: the paths are already listed by "What would land", so the sidebar adds nothing and the button stays away. Prepare writes one unreferenced commit object for a non-fast-forward landing, because the approval matches a commit whose parents are the base tip and the approved head, and that commit has to exist to be matched. Nothing visible changes and git collects it.
forge/landing/land_test.go +78
| @@ -297,3 +297,81 @@ func TestLandRefusesAClosedRequest(t *testing.T) { | |||
| 297 | 297 | t.Fatal("landed a closed request") | |
| 298 | 298 | } | |
| 299 | 299 | } | |
| 300 | |||
| 301 | // Prepare is what the page asks before drawing a merge control, so it has to report a | ||
| 302 | // conflict rather than only refusing one: the control is drawn from the plan, and the | ||
| 303 | // paths are what the "What would land" section shows. | ||
| 304 | func TestPrepareReportsConflictsWithoutMovingAnything(t *testing.T) { | ||
| 305 | t.Parallel() | ||
| 306 | f := newFixture(t) | ||
| 307 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 308 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 309 | t.Fatal(err) | ||
| 310 | } | ||
| 311 | head := f.commit(base, "readme.md", "branch\n", "branch edit") | ||
| 312 | f.protectMain(1) | ||
| 313 | id := f.review("keenan", head, "reviewer") | ||
| 314 | moved := f.commit(base, "readme.md", "base edit\n", "base edit") | ||
| 315 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { | ||
| 316 | t.Fatal(err) | ||
| 317 | } | ||
| 318 | |||
| 319 | plan, err := Prepare(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) | ||
| 320 | if err != nil { | ||
| 321 | t.Fatalf("prepare: %v", err) | ||
| 322 | } | ||
| 323 | if len(plan.Conflicts) == 0 || plan.Conflicts[0] != "readme.md" { | ||
| 324 | t.Fatalf("conflicts = %v, want [readme.md]", plan.Conflicts) | ||
| 325 | } | ||
| 326 | if plan.NewTip != "" { | ||
| 327 | t.Fatalf("NewTip = %s, want empty when the plan cannot land", plan.NewTip) | ||
| 328 | } | ||
| 329 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved { | ||
| 330 | t.Fatalf("main = %s, want the untouched %s", tip, moved) | ||
| 331 | } | ||
| 332 | } | ||
| 333 | |||
| 334 | // A clean non-fast-forward lands with a merge commit, and the plan says so before | ||
| 335 | // anything moves — the same answer the write then carries out. | ||
| 336 | func TestPrepareDescribesAMergeCommit(t *testing.T) { | ||
| 337 | t.Parallel() | ||
| 338 | f := newFixture(t) | ||
| 339 | base := f.commit("", "readme.md", "v1\n", "base") | ||
| 340 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { | ||
| 341 | t.Fatal(err) | ||
| 342 | } | ||
| 343 | head := f.commit(base, "topic.md", "topic\n", "branch") | ||
| 344 | f.protectMain(1) | ||
| 345 | id := f.review("keenan", head, "reviewer") | ||
| 346 | moved := f.commit(base, "other.md", "other\n", "landed first") | ||
| 347 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { | ||
| 348 | t.Fatal(err) | ||
| 349 | } | ||
| 350 | |||
| 351 | plan, err := Prepare(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) | ||
| 352 | if err != nil { | ||
| 353 | t.Fatalf("prepare: %v", err) | ||
| 354 | } | ||
| 355 | if !plan.MergeCommit || len(plan.Conflicts) > 0 || plan.NewTip == "" { | ||
| 356 | t.Fatalf("plan = merge %t conflicts %v tip %q, want a merge commit", plan.MergeCommit, plan.Conflicts, plan.NewTip) | ||
| 357 | } | ||
| 358 | parents, err := f.g.CommitParents(plan.NewTip) | ||
| 359 | if err != nil { | ||
| 360 | t.Fatal(err) | ||
| 361 | } | ||
| 362 | if len(parents) != 2 || parents[0] != moved || parents[1] != head { | ||
| 363 | t.Fatalf("parents = %v, want [%s %s]", parents, moved, head) | ||
| 364 | } | ||
| 365 | // Preparing wrote an unreferenced commit and moved nothing. | ||
| 366 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved { | ||
| 367 | t.Fatalf("main = %s, want the untouched %s", tip, moved) | ||
| 368 | } | ||
| 369 | // The plan the write carries out is the same one: same tip, same shape. | ||
| 370 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) | ||
| 371 | if err != nil { | ||
| 372 | t.Fatalf("land: %v", err) | ||
| 373 | } | ||
| 374 | if res.NewTip != plan.NewTip { | ||
| 375 | t.Fatalf("landed %s but the plan said %s", res.NewTip, plan.NewTip) | ||
| 376 | } | ||
| 377 | } | ||