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.
Land a change request on the server instead of by pushing
38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 14d 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.
forge/landing/land.go +205
| @@ -0,0 +1,205 @@ | |||
| 1 | package landing | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "strings" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/rodent-software/vole/internal/format" | ||
| 10 | "github.com/rodent-software/vole/metadata" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // Result describes one landing. | ||
| 14 | type Result struct { | ||
| 15 | Request *metadata.Request | ||
| 16 | // Base is the branch written, NewTip is the commit now on it and OldTip is what | ||
| 17 | // it replaced. They differ from the request's head when the landing needed a | ||
| 18 | // merge commit. | ||
| 19 | Base string | ||
| 20 | OldTip string | ||
| 21 | NewTip string | ||
| 22 | // Approval is the request and approvers that authorised the landing. It is nil | ||
| 23 | // when the landing needed no approval (an unprotected branch, or an admin). | ||
| 24 | Approval *metadata.Merge | ||
| 25 | // FastForward is true when NewTip is the reviewed head itself, which is the | ||
| 26 | // usual case: nothing new was written, so the commit that passed CI is the | ||
| 27 | // commit that landed. | ||
| 28 | FastForward bool | ||
| 29 | // MergeCommit is true when a two-parent commit of the base tip and the reviewed | ||
| 30 | // head was written, which is the only other shape the gate accepts. | ||
| 31 | MergeCommit bool | ||
| 32 | // AlreadyLanded is true when the head was already in the base branch: a retry of | ||
| 33 | // a landing whose metadata write failed, or a request that was landed by pushing | ||
| 34 | // the branch before this command existed. | ||
| 35 | AlreadyLanded bool | ||
| 36 | } | ||
| 37 | |||
| 38 | // ErrLandedUnmarked is returned when the branch moved but the request could not be | ||
| 39 | // marked merged. The landing stands — refusing it now would be a lie — so the error | ||
| 40 | // carries the commit that landed and the caller is expected to report it and let a | ||
| 41 | // retry finish the marking. | ||
| 42 | type ErrLandedUnmarked struct { | ||
| 43 | NewTip string | ||
| 44 | Err error | ||
| 45 | } | ||
| 46 | |||
| 47 | func (e *ErrLandedUnmarked) Error() string { | ||
| 48 | return fmt.Sprintf("landed %s, but could not mark the change request merged: %v", | ||
| 49 | format.SHA(e.NewTip), e.Err) | ||
| 50 | } | ||
| 51 | func (e *ErrLandedUnmarked) Unwrap() error { return e.Err } | ||
| 52 | |||
| 53 | // attempts bounds the retry loop around a ref that keeps moving. A landing is | ||
| 54 | // cheap to recompute, so a couple of tries turn a race into a success instead of | ||
| 55 | // an error the caller has to explain. | ||
| 56 | const attempts = 3 | ||
| 57 | |||
| 58 | // Land merges an open change request into its base branch and records that it | ||
| 59 | // happened. | ||
| 60 | // | ||
| 61 | // It moves the ref itself, so it does not run the pre-receive hook; g.Check is the | ||
| 62 | // same gate the hook would have applied, asked in the same terms and answered in the | ||
| 63 | // same words. A landing that the hook would refuse is refused here. | ||
| 64 | // | ||
| 65 | // The ref moves before the request is marked merged. If the process dies between | ||
| 66 | // the two, the branch is correct and the request still looks open; running the same | ||
| 67 | // command again finds the head already in the base and only writes the mark. | ||
| 68 | func Land(g Gate, id, actor string, now time.Time) (*Result, error) { | ||
| 69 | var lastRace error | ||
| 70 | for attempt := 0; attempt < attempts; attempt++ { | ||
| 71 | res, done, err := landOnce(g, id, actor, now) | ||
| 72 | if err != nil { | ||
| 73 | return res, err | ||
| 74 | } | ||
| 75 | if done { | ||
| 76 | return res, nil | ||
| 77 | } | ||
| 78 | lastRace = fmt.Errorf("%s moved while this landing was being prepared; try again", res.Base) | ||
| 79 | } | ||
| 80 | return nil, lastRace | ||
| 81 | } | ||
| 82 | |||
| 83 | // landOnce is one attempt. done is false only when the compare-and-swap lost a race | ||
| 84 | // with another writer. | ||
| 85 | func landOnce(g Gate, id, actor string, now time.Time) (*Result, bool, error) { | ||
| 86 | if g.Repo == nil { | ||
| 87 | return nil, false, errors.New("no repository to land in") | ||
| 88 | } | ||
| 89 | req, err := metadata.GetRequest(g.Repo, id) | ||
| 90 | if err != nil { | ||
| 91 | return nil, false, err | ||
| 92 | } | ||
| 93 | branch := req.Base | ||
| 94 | ref := g.Ref(branch) | ||
| 95 | oldTip, err := g.Repo.RevParse(ref) | ||
| 96 | if err != nil { | ||
| 97 | return nil, false, err | ||
| 98 | } | ||
| 99 | if oldTip == "" { | ||
| 100 | return nil, false, fmt.Errorf("%s does not exist, so there is nothing to land into", ref) | ||
| 101 | } | ||
| 102 | if req.Head == "" { | ||
| 103 | return nil, false, fmt.Errorf("change request %s names no head commit", id) | ||
| 104 | } | ||
| 105 | head, err := g.Repo.RevParse(req.Head) | ||
| 106 | if err != nil { | ||
| 107 | return nil, false, err | ||
| 108 | } | ||
| 109 | if head == "" { | ||
| 110 | return nil, false, fmt.Errorf( | ||
| 111 | "change request %s names head %s, which is not in this repository; push the branch again", | ||
| 112 | id, format.SHA(req.Head)) | ||
| 113 | } | ||
| 114 | res := &Result{Request: req, Base: branch, OldTip: oldTip, NewTip: head} | ||
| 115 | if req.State == "closed" { | ||
| 116 | return res, false, fmt.Errorf("change request %s is closed; reopen it before merging", id) | ||
| 117 | } | ||
| 118 | |||
| 119 | // Already in the base branch: nothing to merge, only the record to write. This is | ||
| 120 | // the retry path after the branch moved and the mark did not, and the path for a | ||
| 121 | // request whose branch was landed by a push. | ||
| 122 | if landed, err := g.Repo.IsAncestor(head, oldTip); err != nil { | ||
| 123 | return res, false, err | ||
| 124 | } else if landed { | ||
| 125 | res.AlreadyLanded = true | ||
| 126 | res.NewTip = oldTip | ||
| 127 | if req.State == "merged" && req.MergedHead != "" { | ||
| 128 | return res, true, nil | ||
| 129 | } | ||
| 130 | if err := marked(g, id, head, actor, now); err != nil { | ||
| 131 | return res, false, &ErrLandedUnmarked{NewTip: oldTip, Err: err} | ||
| 132 | } | ||
| 133 | return res, true, nil | ||
| 134 | } | ||
| 135 | |||
| 136 | // Fast-forward when the base is an ancestor of the reviewed head: the commit that | ||
| 137 | // passed CI is then exactly the commit that lands, which is the property worth | ||
| 138 | // keeping. Otherwise a two-parent merge commit, the only other shape the gate | ||
| 139 | // accepts, so the reviewed head is still one of its parents. | ||
| 140 | ff, err := g.Repo.IsAncestor(oldTip, head) | ||
| 141 | if err != nil { | ||
| 142 | return res, false, err | ||
| 143 | } | ||
| 144 | newTip := head | ||
| 145 | if ff { | ||
| 146 | res.FastForward = true | ||
| 147 | } else { | ||
| 148 | tree, conflicts, err := g.Repo.MergeTree(oldTip, head) | ||
| 149 | if err != nil { | ||
| 150 | return res, false, err | ||
| 151 | } | ||
| 152 | if len(conflicts) > 0 { | ||
| 153 | return res, false, fmt.Errorf( | ||
| 154 | "%s does not merge into %s: %s conflict. Resolve it on the branch and push again", | ||
| 155 | format.SHA(head), branch, strings.Join(conflicts, ", ")) | ||
| 156 | } | ||
| 157 | commit, err := g.Repo.CommitMergeTree(tree, []string{oldTip, head}, | ||
| 158 | fmt.Sprintf("Merge change request %s: %s", req.ID, req.Title), actor) | ||
| 159 | if err != nil { | ||
| 160 | return res, false, err | ||
| 161 | } | ||
| 162 | newTip = commit | ||
| 163 | res.MergeCommit = true | ||
| 164 | } | ||
| 165 | res.NewTip = newTip | ||
| 166 | |||
| 167 | // The same question the hook would have asked of a push of this commit. | ||
| 168 | merge, err := g.Check(branch, newTip, oldTip) | ||
| 169 | if err != nil { | ||
| 170 | return res, false, err | ||
| 171 | } | ||
| 172 | res.Approval = merge | ||
| 173 | |||
| 174 | ok, err := g.Repo.UpdateRefCAS(ref, newTip, oldTip) | ||
| 175 | if err != nil { | ||
| 176 | return res, false, err | ||
| 177 | } | ||
| 178 | if !ok { | ||
| 179 | return res, false, nil // another writer won; the caller retries | ||
| 180 | } | ||
| 181 | if g.Audit != nil { | ||
| 182 | if merge != nil { | ||
| 183 | g.Audit(fmt.Sprintf("merge %s via request %s (head %s) approved by %s", | ||
| 184 | ref, merge.Request.ID, format.SHA(merge.Request.Head), strings.Join(merge.Approvers, ","))) | ||
| 185 | } else { | ||
| 186 | g.Audit(fmt.Sprintf("merge %s by %s (head %s)", ref, actor, format.SHA(newTip))) | ||
| 187 | } | ||
| 188 | } | ||
| 189 | if err := marked(g, id, newTip, actor, now); err != nil { | ||
| 190 | return res, false, &ErrLandedUnmarked{NewTip: newTip, Err: err} | ||
| 191 | } | ||
| 192 | return res, true, nil | ||
| 193 | } | ||
| 194 | |||
| 195 | // marked writes the merged state and turns a validation problem into an error, so a | ||
| 196 | // landing never leaves a request file the metadata reader would refuse. | ||
| 197 | func marked(g Gate, id, head, actor string, now time.Time) error { | ||
| 198 | if _, _, err := metadata.MarkMerged(g.Repo, id, head, actor, now); err != nil { | ||
| 199 | return err | ||
| 200 | } | ||
| 201 | if problems := metadata.ValidateRequests(g.Repo, metadata.Ref); len(problems) > 0 { | ||
| 202 | return fmt.Errorf("the request tree is invalid after marking it merged: %s", strings.Join(problems, "; ")) | ||
| 203 | } | ||
| 204 | return nil | ||
| 205 | } | ||