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/gitx/merge.go +71

@@ -0,0 +1,71 @@
1 package gitx
2
3 // "Would this land?" without a worktree.
4 //
5 // `git merge-tree --write-tree` performs the merge in memory and names the paths
6 // that conflict. A change request page can then say "this needs a rebase" instead
7 // of leaving the discovery to whoever runs `git merge` locally an hour later.
8 // Nothing is written: the tree it names is thrown away and the repository is
9 // unchanged, which is what makes it safe to run on a page view.
10
11 import (
12 "errors"
13 "fmt"
14 "os/exec"
15 "regexp"
16 "strings"
17 )
18
19 // treeIDRE is what a successful merge prints first: the id of the tree it built.
20 var treeIDRE = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`)
21
22 // MergeConflicts reports the paths that would conflict if head were merged into
23 // base. An empty slice means the merge is clean; an error means the question could
24 // not be asked.
25 //
26 // merge-tree exits 1 both when the merge conflicts and when it cannot resolve a
27 // revision, so the exit status alone is not an answer. What separates them is the
28 // output: a real merge prints the resulting tree's id first, and a refusal prints
29 // a message instead.
30 func (r *Repo) MergeConflicts(base, head string) ([]string, error) {
31 out, errs, err := r.run(nil, "merge-tree", "--write-tree", "--name-only", "--no-messages", base, head)
32 text := string(out)
33 if err != nil {
34 var ee *exec.ExitError
35 if !errors.As(err, &ee) || ee.ExitCode() != 1 {
36 return nil, fmt.Errorf("merge-tree %s %s: %w: %s", base, head, err, errs)
37 }
38 if !treeIDAhead(text) {
39 reason := strings.TrimSpace(text)
40 if reason == "" {
41 reason = errs
42 }
43 return nil, fmt.Errorf("cannot merge %s into %s: %s", head, base, reason)
44 }
45 }
46 return parseMergeTree(text), nil
47 }
48
49 // treeIDAhead reports whether the output starts with a tree id rather than a
50 // complaint.
51 func treeIDAhead(out string) bool {
52 first, _, _ := strings.Cut(out, "\n")
53 return treeIDRE.MatchString(strings.TrimSpace(first))
54 }
55
56 // parseMergeTree reads --write-tree output: the merged tree's id, then one
57 // conflicted path per line. A clean merge prints the id and nothing else.
58 func parseMergeTree(out string) []string {
59 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
60 if len(lines) < 2 {
61 return nil
62 }
63 var paths []string
64 for _, line := range lines[1:] {
65 if strings.TrimSpace(line) == "" {
66 break // informational messages follow, and they are not paths
67 }
68 paths = append(paths, line)
69 }
70 return paths
71 }