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.

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 }