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 | } | ||