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.
Test the merge result before landing it
e5478085e5b53a2892d0649d6f07c6f0a5653378 · Keenan Nemetz · 11d ago · parent db5e4382
Test the merge result before landing it A protected branch could receive a non-fast-forward landing whose tree was never built: the gate asked for checks on the request head, and the merge commit was made at landing time. A pushed merge with the right parents but extra content was accepted too. Publish the merge commit under refs/vole/candidates/, test it as the base branch, and make the gate require its results. Approvals name the head and the base branch, so a moved base retests without a new approval. A merger who clicks while that run is still going waits for it instead of clicking again. A pushed merge commit now has to carry exactly the tree a clean merge would produce. Closes: 01M61QQ56TESQ1HTMB1Z5C3733
metadata/requests.go +59−1
| @@ -31,6 +31,7 @@ import ( | |||
| 31 | 31 | ||
| 32 | 32 | "github.com/rodent-software/vole/core/git" | |
| 33 | 33 | voleid "github.com/rodent-software/vole/core/id" | |
| 34 | "github.com/rodent-software/vole/internal/format" | ||
| 34 | 35 | ) | |
| 35 | 36 | ||
| 36 | 37 | const ( | |
| @@ -481,6 +482,33 @@ type Merge struct { | |||
| 481 | 482 | Approvers []string | |
| 482 | 483 | } | |
| 483 | 484 | ||
| 485 | // TreeMismatchError is a pushed merge commit whose parents name a request but whose | ||
| 486 | // tree carries something else. It is separate from "no match" because the refusal | ||
| 487 | // has to say what is wrong: the commit has the shape of a landing without being one. | ||
| 488 | type TreeMismatchError struct { | ||
| 489 | Request *Request | ||
| 490 | NewTip string | ||
| 491 | Expected string | ||
| 492 | Actual string | ||
| 493 | Conflicts []string | ||
| 494 | } | ||
| 495 | |||
| 496 | func (e *TreeMismatchError) Error() string { | ||
| 497 | if len(e.Conflicts) > 0 { | ||
| 498 | return fmt.Sprintf( | ||
| 499 | "the merge commit %s resolves conflicts between %s and change request %s (head %s) that "+ | ||
| 500 | "nobody reviewed. Push the branch, resolve the conflicts there, and get the new head "+ | ||
| 501 | "approved and tested", | ||
| 502 | format.SHA(e.NewTip), e.Request.Base, e.Request.ID, format.SHA(e.Request.Head)) | ||
| 503 | } | ||
| 504 | return fmt.Sprintf( | ||
| 505 | "the merge commit %s does not carry the result of merging %s into this branch: "+ | ||
| 506 | "its tree is %s, but the merge of change request %s (head %s) is %s. "+ | ||
| 507 | "A merge may not add content nobody approved; push the branch and merge it with Vole instead", | ||
| 508 | format.SHA(e.NewTip), e.Request.Base, format.SHA(e.Actual), e.Request.ID, | ||
| 509 | format.SHA(e.Request.Head), format.SHA(e.Expected)) | ||
| 510 | } | ||
| 511 | |||
| 484 | 512 | // FindApprovedMatch reports whether a push to a protected branch is authorised by | |
| 485 | 513 | // an approved request. | |
| 486 | 514 | // | |
| @@ -506,7 +534,7 @@ func FindApprovedMatch(g *git.Repo, list []*Request, req ApprovalRequirement) (* | |||
| 506 | 534 | switch { | |
| 507 | 535 | case r.Head == req.NewTip: | |
| 508 | 536 | matches = true | |
| 509 | case req.OldTip != "" && req.OldTip != git.ZeroSHA: | ||
| 537 | case req.OldTip != "" && req.OldTip != git.ZeroSHA && g != nil: | ||
| 510 | 538 | parents, err := g.CommitParents(req.NewTip) | |
| 511 | 539 | if err != nil { | |
| 512 | 540 | nearest = err | |
| @@ -516,6 +544,36 @@ func FindApprovedMatch(g *git.Repo, list []*Request, req ApprovalRequirement) (* | |||
| 516 | 544 | matches = (parents[0] == req.OldTip && parents[1] == r.Head) || | |
| 517 | 545 | (parents[1] == req.OldTip && parents[0] == r.Head) | |
| 518 | 546 | } | |
| 547 | if matches { | ||
| 548 | // Parents alone are not enough: "merge, then push" could carry a tree | ||
| 549 | // nobody approved. A landing merge must be exactly the merge of the | ||
| 550 | // reviewed head and the branch tip. | ||
| 551 | expected, conflicts, err := g.MergeTree(req.OldTip, r.Head) | ||
| 552 | if err != nil { | ||
| 553 | nearest = err | ||
| 554 | continue | ||
| 555 | } | ||
| 556 | if len(conflicts) > 0 { | ||
| 557 | // A conflict has no clean merge to compare against. Refuse it and | ||
| 558 | // let the author push a new head rather than accepting a resolution | ||
| 559 | // the server did not review. | ||
| 560 | nearest = &TreeMismatchError{ | ||
| 561 | Request: r, NewTip: req.NewTip, Conflicts: conflicts, | ||
| 562 | } | ||
| 563 | continue | ||
| 564 | } | ||
| 565 | actual, err := g.TreeOf(req.NewTip) | ||
| 566 | if err != nil { | ||
| 567 | nearest = err | ||
| 568 | continue | ||
| 569 | } | ||
| 570 | if actual != expected { | ||
| 571 | nearest = &TreeMismatchError{ | ||
| 572 | Request: r, NewTip: req.NewTip, Expected: expected, Actual: actual, | ||
| 573 | } | ||
| 574 | continue | ||
| 575 | } | ||
| 576 | } | ||
| 519 | 577 | } | |
| 520 | 578 | if !matches { | |
| 521 | 579 | continue | |