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.
Make change requests follow branches and allow base retargeting
e050e14a64f6f19c1b1478c4c073d690ef3d3129 · Keenan Nemetz · 13d ago · parent 59464445
Make change requests follow branches and allow base retargeting A request opened from a branch now follows it: post-receive moves the request's head to the new tip after an accepted push, so approvals and checks that named the old tip stop counting. Approvals stay bound to an exact base and commit; the approval writers now require the base and head the approver saw, so a push or retarget cannot silently approve a target nobody reviewed. Merged requests freeze the reviewed head. Add `vole change update --base` and a Change base control for stacked requests. A new base changes the diff and therefore invalidates approvals, which is what retargeting an upper request after its lower branch lands needs. Base names may now contain slashes. Also fix a second-boundary flake in the landing test.
metadata/requests.go +18−18
| @@ -12,7 +12,8 @@ | |||
| 12 | 12 | // An approval names the exact commit it reviewed. That is the whole point: a | |
| 13 | 13 | // general "I approve this request" is worthless, because the head can move | |
| 14 | 14 | // afterwards. If the head changes, the approvals no longer match and the gate | |
| 15 | // closes again. | ||
| 15 | // closes again. A request opened from a branch follows that branch, so a push moves | ||
| 16 | // its head; a request opened from a bare commit stays pinned to that commit. | ||
| 16 | 17 | // | |
| 17 | 18 | // Landing is either a push the pre-receive hook accepts — it permits a protected | |
| 18 | 19 | // branch when the push carries an approved request — or the server's own merge | |
| @@ -42,7 +43,6 @@ const ( | |||
| 42 | 43 | var ( | |
| 43 | 44 | shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`) | |
| 44 | 45 | requestPathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(request\.md)|/approvals/([a-z0-9][a-z0-9_-]{1,31})\.md|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | |
| 45 | branchNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | ||
| 46 | 46 | ) | |
| 47 | 47 | ||
| 48 | 48 | type Request struct { | |
| @@ -51,11 +51,11 @@ type Request struct { | |||
| 51 | 51 | Author string // account name: what the approval gate compares against | |
| 52 | 52 | Base string // branch name, e.g. "main" | |
| 53 | 53 | Head string // commit sha under review | |
| 54 | // HeadRef is the branch the head was taken from when the request was opened, | ||
| 55 | // e.g. "contrib/alice/fix". It is a display hint, not part of the review: the | ||
| 56 | // commit in Head is what approvals, checks, the diff and the landing gate name. | ||
| 57 | // It is empty for requests opened from a bare commit, for older records, and it | ||
| 58 | // may no longer point at Head if the branch has moved or been deleted. | ||
| 54 | // HeadRef is the branch the request follows, e.g. "contrib/alice/fix". A push | ||
| 55 | // that moves that branch moves Head to the new tip, which makes approvals of the | ||
| 56 | // old tip stale and asks for checks on the new one. It is empty for requests | ||
| 57 | // opened from a bare commit, for older records, and for a request whose branch | ||
| 58 | // has been deleted (Head keeps the last tip it saw). | ||
| 59 | 59 | HeadRef string | |
| 60 | 60 | State string // open | merged | closed | |
| 61 | 61 | Created time.Time | |
| @@ -256,19 +256,19 @@ func applyRequest(req *Request, raw, path string, problems *[]string) { | |||
| 256 | 256 | "%s: author %q is not an account name", path, req.Author)) | |
| 257 | 257 | } | |
| 258 | 258 | req.Base = strings.TrimSpace(m.Base) | |
| 259 | if !branchNameRE.MatchString(req.Base) { | ||
| 259 | if !validBranchName(req.Base) { | ||
| 260 | 260 | *problems = append(*problems, fmt.Sprintf("%s: base must be a branch name, got %q", path, req.Base)) | |
| 261 | 261 | } | |
| 262 | 262 | req.Head = strings.TrimSpace(m.Head) | |
| 263 | 263 | if req.Head != "" && !shaRE.MatchString(req.Head) { | |
| 264 | 264 | *problems = append(*problems, fmt.Sprintf("%s: head must be a full 40-character commit id, got %q", path, req.Head)) | |
| 265 | 265 | } | |
| 266 | // The branch is a display hint, and a branch with no commit behind it says | ||
| 267 | // nothing: the commit is what the request is about. | ||
| 266 | // HeadRef names the branch this request follows. It needs a commit behind it so | ||
| 267 | // a reader that cannot see the branch still knows what was last reviewed. | ||
| 268 | 268 | req.HeadRef = strings.TrimSpace(m.HeadRef) | |
| 269 | 269 | if req.HeadRef != "" { | |
| 270 | 270 | switch { | |
| 271 | case !validHeadRef(req.HeadRef): | ||
| 271 | case !validBranchName(req.HeadRef): | ||
| 272 | 272 | *problems = append(*problems, fmt.Sprintf( | |
| 273 | 273 | "%s: head_ref must be a branch name, got %q", path, req.HeadRef)) | |
| 274 | 274 | case req.Head == "": | |
| @@ -338,12 +338,12 @@ func applyRequest(req *Request, raw, path string, problems *[]string) { | |||
| 338 | 338 | req.Description = body | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | // validHeadRef is the file-format check for head_ref: one line, no spaces, and not a | ||
| 342 | // full ref. It is deliberately looser than git's ref rules because the branch the | ||
| 343 | // request names may not exist yet on the reader's side, and because git is the | ||
| 344 | // authority where the branch does exist (Repo.ValidBranch at the writer). A reader | ||
| 345 | // that refused a name git accepts would reject a request that was written correctly. | ||
| 346 | func validHeadRef(name string) bool { | ||
| 341 | // validBranchName is the file-format check for base and head_ref: one line, no | ||
| 342 | // spaces, and not a full ref. It is deliberately looser than git's ref rules because | ||
| 343 | // the branch a request names may not exist yet on the reader's side, and because git | ||
| 344 | // is the authority where the branch does exist (Repo.ValidBranch at the writer). A | ||
| 345 | // reader that refused a name git accepts would reject a request written correctly. | ||
| 346 | func validBranchName(name string) bool { | ||
| 347 | 347 | if name == "" || strings.HasPrefix(name, "refs/") || strings.ContainsAny(name, " \t") { | |
| 348 | 348 | return false | |
| 349 | 349 | } | |
| @@ -397,7 +397,7 @@ func parseApproval(principal, raw, path string) (Approval, string) { | |||
| 397 | 397 | return a, fmt.Sprintf("%s: head must be the full commit id that was reviewed", path) | |
| 398 | 398 | } | |
| 399 | 399 | a.Head = m.Head | |
| 400 | if !branchNameRE.MatchString(m.Base) { | ||
| 400 | if !validBranchName(m.Base) { | ||
| 401 | 401 | return a, fmt.Sprintf("%s: base must name the branch you approved (e.g. \"main\")", path) | |
| 402 | 402 | } | |
| 403 | 403 | a.Base = m.Base | |