vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0: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 · 13d 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.
metadata/request_write.go +57−1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package metadata | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | "errors" | ||
| 4 | 5 | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| @@ -65,6 +66,11 @@ func renderRequest(r *Request) ([]byte, error) { | |||
| 65 | 66 | d.Word("base", r.Base) | |
| 66 | 67 | d.Word("head", r.Head) | |
| 67 | 68 | d.Word("created", r.Created.UTC().Format(time.RFC3339)) | |
| 69 | if r.State == "merged" { | ||
| 70 | d.Word("merged_head", r.MergedHead) | ||
| 71 | d.Word("merged_by", r.MergedBy) | ||
| 72 | d.Word("merged_at", r.MergedAt.UTC().Format(time.RFC3339)) | ||
| 73 | } | ||
| 68 | 74 | return d.Bytes(strings.TrimRight(r.Description, "\n")) | |
| 69 | 75 | } | |
| 70 | 76 | ||
| @@ -172,8 +178,12 @@ func ApproveRequest(g *git.Repo, id, principal string, now time.Time, actor stri | |||
| 172 | 178 | ||
| 173 | 179 | // SetState moves a request between open and closed. | |
| 174 | 180 | func SetRequestState(g *git.Repo, id, state, actor string) (*Request, error) { | |
| 181 | // "merged" is not a state a caller may set: it is the outcome of a landing, and | ||
| 182 | // it has to travel with the commit that landed. Use MarkMerged, which writes the | ||
| 183 | // state and the commit together, so a request cannot claim a merge no commit | ||
| 184 | // backs. | ||
| 175 | 185 | switch state { | |
| 176 | case "open", "merged", "closed": | ||
| 186 | case "open", "closed": | ||
| 177 | 187 | default: | |
| 178 | 188 | return nil, fmt.Errorf("requests: state must be open or closed") | |
| 179 | 189 | } | |
| @@ -196,6 +206,52 @@ func SetRequestState(g *git.Repo, id, state, actor string) (*Request, error) { | |||
| 196 | 206 | return out, err | |
| 197 | 207 | } | |
| 198 | 208 | ||
| 209 | // MarkMerged records a landing: the state becomes merged and the request names the | ||
| 210 | // commit that landed, who landed it, and when. The commit is written in the same | ||
| 211 | // commit as the state, so the file cannot say "merged" and mean nothing. | ||
| 212 | // | ||
| 213 | // A request that is already merged with the same commit is left alone and returned | ||
| 214 | // unchanged, which makes a retry after a half-finished landing harmless. | ||
| 215 | func MarkMerged(g *git.Repo, id, landedHead, actor string, at time.Time) (*Request, bool, error) { | ||
| 216 | if !shaRE.MatchString(landedHead) { | ||
| 217 | return nil, false, fmt.Errorf("requests: merged head must be a full commit id, got %q", landedHead) | ||
| 218 | } | ||
| 219 | var ( | ||
| 220 | out *Request | ||
| 221 | changed bool | ||
| 222 | ) | ||
| 223 | base := fmt.Sprintf("%s/%s/", RequestsDir, id) | ||
| 224 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 225 | r, err := loadFrom(files, id) | ||
| 226 | if err != nil { | ||
| 227 | return err | ||
| 228 | } | ||
| 229 | if r.State == "merged" && r.MergedHead == landedHead { | ||
| 230 | out = r | ||
| 231 | return ErrNoChange | ||
| 232 | } | ||
| 233 | r.State = "merged" | ||
| 234 | r.MergedHead = landedHead | ||
| 235 | r.MergedBy = actor | ||
| 236 | r.MergedAt = at.UTC() | ||
| 237 | raw, err := renderRequest(r) | ||
| 238 | if err != nil { | ||
| 239 | return err | ||
| 240 | } | ||
| 241 | files[base+requestName] = raw | ||
| 242 | out = r | ||
| 243 | changed = true | ||
| 244 | return nil | ||
| 245 | }, fmt.Sprintf("request: %s merged %s", id, landedHead), actor) | ||
| 246 | if errors.Is(err, ErrNoChange) { | ||
| 247 | return out, false, nil | ||
| 248 | } | ||
| 249 | if err != nil { | ||
| 250 | return nil, false, err | ||
| 251 | } | ||
| 252 | return out, changed, nil | ||
| 253 | } | ||
| 254 | |||
| 199 | 255 | // NewComment builds a comment on a request with a fresh id. | |
| 200 | 256 | func NewRequestComment(body, author string, now time.Time) (*Comment, error) { | |
| 201 | 257 | commentID, err := voleid.New(now) | |