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.
Phase 3: a request says whether it would actually land
3c5d11faadd2f1ca4788e0f7f41a77a11840517e · Keenan Nemetz · 17d ago · parent a63475ce
Phase 3: a request says whether it would actually land A diff answers "what changed" and nothing else. A branch can read perfectly line by line and still refuse to merge, and until now nobody found that out until they ran `git merge` locally — after an approval had been given. The request page now asks `git merge-tree --write-tree --name-only <base-tip> <head>`, which merges in memory without a worktree or an index, and says either "this branch no longer merges into main cleanly: code.txt — rebase or merge main before landing it" or "merges into main cleanly". `vole request show` prints the same line from a clone. Three rules, each because the obvious version is wrong: - Surfaced, not gated. A gate would only ever be right at the instant it ran: the base can move after an approval, and the roadmap asked for detection before the push rather than a second way to refuse one. - Silence when the question cannot be asked. merge-tree exits 1 both for a conflict and for a revision it cannot resolve, so the exit status alone is not an answer; the answer is believed only when the output starts with a tree id, which is what a real merge prints first. A base branch that is gone, or a head that was never pushed, therefore produces no claim at all — a page that says "clean" about a commit it could not load is worse than one that says nothing. - The clone says what it does not know. `vole request show` reports "cannot check: <sha> is not in this clone (git fetch origin)" rather than reading a head it does not have as mergeable. The same view now states the requirement, "0 of 1 required approval(s)", and counts the approvals that currently apply — the number used to live in the reviewer's head. Verified: TestMergeConflictsNamesThePaths and TestMergeConflictsIsEmptyWhenItWould Land build two branches that touch the same line and one that touches another file; TestMergeConflictsReportsAnUnaskableQuestion pins that an unknown revision is an error rather than "no conflicts" (that is the bug the tree-id check exists for); TestParseMergeTree covers clean, empty, one path, two paths and the message section; TestRequestPageSaysWhenItWouldConflict drives a real conflict through the binary — main moves the same line after the feature branch was opened — checks the page, the "0 of 1" line, a clean branch, the CLI's conflict line, the CLI's "cannot check" for a head that is not in a fresh clone, and a request whose head was never pushed, which must say nothing. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
internal/web/requests.go +35−1
| @@ -131,6 +131,35 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 131 | 131 | } | |
| 132 | 132 | } | |
| 133 | 133 | added, removed := diffSummary(files) | |
| 134 | |||
| 135 | // Would it land? A diff says what changed; it cannot say whether the branch | ||
| 136 | // still merges. merge-tree answers that in memory, so the reviewer sees "this | ||
| 137 | // needs a rebase" before anybody approves — and the answer is only claimed | ||
| 138 | // when the question could actually be asked (base branch present, head | ||
| 139 | // reachable), because a page that says "clean" when it did not check is worse | ||
| 140 | // than one that says nothing. | ||
| 141 | var conflicts []string | ||
| 142 | mergeChecked := false | ||
| 143 | if req.Open() && req.Head != "" { | ||
| 144 | if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil && tip != "" { | ||
| 145 | if paths, err := m.Git().MergeConflicts(tip, req.Head); err == nil { | ||
| 146 | conflicts, mergeChecked = paths, true | ||
| 147 | } | ||
| 148 | } | ||
| 149 | } | ||
| 150 | // How many approvals the gate wants, and how many currently apply. The page | ||
| 151 | // used to show the approvals and leave the requirement to memory. | ||
| 152 | required := 0 | ||
| 153 | if a, err := m.ACL(); err == nil { | ||
| 154 | required = a.RequiredApprovals() | ||
| 155 | } | ||
| 156 | current := 0 | ||
| 157 | for _, a := range approvals { | ||
| 158 | if a.Current { | ||
| 159 | current++ | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 134 | 163 | w.render(rw, "request", struct { | |
| 135 | 164 | Page | |
| 136 | 165 | Name string | |
| @@ -143,7 +172,12 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 143 | 172 | Removed int | |
| 144 | 173 | Truncated bool | |
| 145 | 174 | AlreadyMerged bool | |
| 175 | Conflicts []string | ||
| 176 | MergeChecked bool | ||
| 177 | Required int | ||
| 178 | Current int | ||
| 146 | 179 | }{Page: w.base(m.Name + " · " + req.Title), Name: m.Name, Request: req, | |
| 147 | 180 | Description: w.renderMarkdown(m.Name, req.Description), Approvals: approvals, Checks: checks, | |
| 148 | Files: files, Added: added, Removed: removed, Truncated: truncated, AlreadyMerged: alreadyMerged}, 0) | ||
| 181 | Files: files, Added: added, Removed: removed, Truncated: truncated, AlreadyMerged: alreadyMerged, | ||
| 182 | Conflicts: conflicts, MergeChecked: mergeChecked, Required: required, Current: current}, 0) | ||
| 149 | 183 | } | |