Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }