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
forge/landing/land.go +67−14
| @@ -29,11 +29,32 @@ type Plan struct { | |||
| 29 | 29 | Base string | |
| 30 | 30 | OldTip string | |
| 31 | 31 | Head string | |
| 32 | // NewTip is the commit that would land: Head for a fast-forward, a merge commit | ||
| 33 | // otherwise. Empty when the plan cannot land. | ||
| 32 | // NewTip is the commit that would land: Head for a fast-forward, the published | ||
| 33 | // combined result otherwise. Empty when the plan cannot land. | ||
| 34 | 34 | NewTip string | |
| 35 | 35 | FastForward bool | |
| 36 | 36 | MergeCommit bool | |
| 37 | // CandidateRef is the server-only ref holding NewTip when a merge is needed. | ||
| 38 | // Empty for a fast-forward, which lands the head itself. | ||
| 39 | CandidateRef string | ||
| 40 | // Blocked is why the approval half of the gate would refuse this landing. It is | ||
| 41 | // reported rather than returned as an error so the page can show the combined | ||
| 42 | // result even before approvals arrive. | ||
| 43 | Blocked string | ||
| 44 | // Gaps are the required jobs that have not passed for NewTip. A plan with gaps | ||
| 45 | // is still useful: it says what CI is doing, not that the request is invalid. | ||
| 46 | Gaps []string | ||
| 47 | // Pending is true when every gap is a wait (queued or running) rather than a | ||
| 48 | // failure. It is what the page uses to offer "Merge when ready" instead of | ||
| 49 | // reporting a red result. | ||
| 50 | Pending bool | ||
| 51 | // Checks is true when a workflow applies to the landing tree. It separates | ||
| 52 | // "every required job passed" from "no workflow applies", which need different | ||
| 53 | // words on a page and in `change show`. | ||
| 54 | Checks bool | ||
| 55 | // Ready is true when Land would move the ref now: approvals satisfied (or not | ||
| 56 | // required) and every required job green for the commit that lands. | ||
| 57 | Ready bool | ||
| 37 | 58 | // AlreadyLanded is true when Head is already in OldTip: there is no ref to move, | |
| 38 | 59 | // only the record to write. It is what a retry finds after a landing whose | |
| 39 | 60 | // metadata write failed, and what a request landed by a push looks like. | |
| @@ -92,6 +113,13 @@ func Land(g Gate, id, actor string, now time.Time) (*Plan, error) { | |||
| 92 | 113 | "%s does not merge into %s: %s conflict. Resolve it on the branch and push again", | |
| 93 | 114 | format.SHA(plan.Head), plan.Base, strings.Join(plan.Conflicts, ", ")) | |
| 94 | 115 | } | |
| 116 | if plan.Blocked != "" { | ||
| 117 | return plan, errors.New(plan.Blocked) | ||
| 118 | } | ||
| 119 | if len(plan.Gaps) > 0 { | ||
| 120 | return plan, fmt.Errorf("checks have not passed for %s:\n %s", | ||
| 121 | format.SHA(plan.NewTip), strings.Join(plan.Gaps, "\n ")) | ||
| 122 | } | ||
| 95 | 123 | if plan.AlreadyLanded { | |
| 96 | 124 | if plan.Request.State == "merged" && plan.Request.MergedHead != "" { | |
| 97 | 125 | return plan, nil | |
| @@ -176,6 +204,9 @@ func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) { | |||
| 176 | 204 | } else if landed { | |
| 177 | 205 | plan.AlreadyLanded = true | |
| 178 | 206 | plan.NewTip = oldTip | |
| 207 | // There is nothing left to test: the commit is in the branch. Land only has | ||
| 208 | // the record to write, and a caller that armed a merge must not wait forever. | ||
| 209 | plan.Ready = true | ||
| 179 | 210 | return plan, nil | |
| 180 | 211 | } | |
| 181 | 212 | ||
| @@ -187,7 +218,10 @@ func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) { | |||
| 187 | 218 | // The commit that passed CI is the commit that lands. | |
| 188 | 219 | plan.FastForward = true | |
| 189 | 220 | } else { | |
| 190 | tree, conflicts, err := g.Repo.MergeTree(oldTip, head) | ||
| 221 | // Publish the combined result before asking about it: the gate reads the | ||
| 222 | // candidate ref, and the runner needs the ref to fetch the commit. A | ||
| 223 | // conflict is a refusal, not a resolution; the branch has to change. | ||
| 224 | cand, conflicts, err := EnsureCandidate(g.Repo, req, oldTip, head) | ||
| 191 | 225 | if err != nil { | |
| 192 | 226 | return plan, err | |
| 193 | 227 | } | |
| @@ -196,22 +230,41 @@ func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) { | |||
| 196 | 230 | plan.NewTip = "" | |
| 197 | 231 | return plan, nil | |
| 198 | 232 | } | |
| 199 | commit, err := g.Repo.CommitMergeTree(tree, []string{oldTip, head}, | ||
| 200 | fmt.Sprintf("Merge change request %s: %s", req.ID, req.Title), actor) | ||
| 233 | plan.MergeCommit = true | ||
| 234 | plan.NewTip = cand.SHA | ||
| 235 | plan.CandidateRef = cand.Ref | ||
| 236 | } | ||
| 237 | |||
| 238 | // The approval half of the gate is asked separately from the checks so the page | ||
| 239 | // can show "testing the combined result" instead of treating a queued run as a | ||
| 240 | // refusal. | ||
| 241 | needsApproval := g.RequiresApprovals(branch) && !g.Admin() | ||
| 242 | if needsApproval { | ||
| 243 | merge, refusal, err := g.Approve(branch, plan.NewTip, oldTip) | ||
| 201 | 244 | if err != nil { | |
| 202 | 245 | return plan, err | |
| 203 | 246 | } | |
| 204 | plan.MergeCommit = true | ||
| 205 | plan.NewTip = commit | ||
| 247 | plan.Approval = merge | ||
| 248 | plan.Blocked = refusal | ||
| 206 | 249 | } | |
| 207 | |||
| 208 | // The same question the hook would have asked of a push of this commit. | ||
| 209 | merge, err := g.Check(branch, plan.NewTip, oldTip) | ||
| 210 | if err != nil { | ||
| 211 | plan.NewTip = "" | ||
| 212 | return plan, err | ||
| 250 | // The commit whose checks count is the one that lands: the candidate for a | ||
| 251 | // merge, the head for a fast-forward. Even an unapproved request gets this, so | ||
| 252 | // the page can show the combined result before anybody approves. | ||
| 253 | if needsApproval && g.ACL.RequiresChecks() { | ||
| 254 | plan.Checks, err = HasRequiredChecks(g.Repo, plan.NewTip, branch) | ||
| 255 | if err != nil { | ||
| 256 | return plan, err | ||
| 257 | } | ||
| 258 | if plan.Checks { | ||
| 259 | gaps, pending, err := g.CheckSummary(plan.NewTip, branch) | ||
| 260 | if err != nil { | ||
| 261 | return plan, err | ||
| 262 | } | ||
| 263 | plan.Gaps = gaps | ||
| 264 | plan.Pending = pending | ||
| 265 | } | ||
| 213 | 266 | } | |
| 214 | plan.Approval = merge | ||
| 267 | plan.Ready = len(plan.Conflicts) == 0 && plan.Blocked == "" && len(plan.Gaps) == 0 | ||
| 215 | 268 | return plan, nil | |
| 216 | 269 | } | |
| 217 | 270 | ||