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/server/integration_test.go +111
| @@ -1051,6 +1051,117 @@ func TestEmptyStatesPrintAWorkingCommand(t *testing.T) { | |||
| 1051 | 1051 | } | |
| 1052 | 1052 | } | |
| 1053 | 1053 | ||
| 1054 | // TestRequestPageSaysWhenItWouldConflict: a diff says what changed, never whether | ||
| 1055 | // the branch still merges, and this page is where an approval is decided. The | ||
| 1056 | // answer comes from `git merge-tree` in the repository — nothing is written — and | ||
| 1057 | // it is only claimed when the question could actually be asked. | ||
| 1058 | func TestRequestPageSaysWhenItWouldConflict(t *testing.T) { | ||
| 1059 | f := newFixture(t) | ||
| 1060 | f.publish(f.repo) | ||
| 1061 | |||
| 1062 | base := workRepo(t, map[string]string{"code.txt": "a\nb\nc\n"}) | ||
| 1063 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 1064 | t.Fatalf("main push: %s", errs) | ||
| 1065 | } | ||
| 1066 | // The feature branch changes the middle line... | ||
| 1067 | feature, head := f.cloneWithChange(t, map[string]string{"code.txt": "a\nFEATURE\nc\n"}, "feature") | ||
| 1068 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 1069 | t.Fatalf("feature push: %s", errs) | ||
| 1070 | } | ||
| 1071 | // ...and main moves the same line before anybody reviews it. | ||
| 1072 | if err := os.WriteFile(filepath.Join(base, "code.txt"), []byte("a\nMAIN\nc\n"), 0o644); err != nil { | ||
| 1073 | t.Fatal(err) | ||
| 1074 | } | ||
| 1075 | if _, errs, err := runGit(t, base, "add", "-A"); err != nil { | ||
| 1076 | t.Fatalf("add: %s", errs) | ||
| 1077 | } | ||
| 1078 | if _, errs, err := runGit(t, base, "commit", "-qm", "main moves too"); err != nil { | ||
| 1079 | t.Fatalf("commit: %s", errs) | ||
| 1080 | } | ||
| 1081 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 1082 | t.Fatalf("second main push: %s", errs) | ||
| 1083 | } | ||
| 1084 | |||
| 1085 | meta := f.newMetaRepo(t) | ||
| 1086 | if err := meta.apply(map[string]string{ | ||
| 1087 | "requests/" + reqID + "/request.md": requestFile("main", head, "keenan", f.ownerID, "why this change\n"), | ||
| 1088 | }, "request"); err != nil { | ||
| 1089 | t.Fatalf("request push: %v", err) | ||
| 1090 | } | ||
| 1091 | |||
| 1092 | code, body := f.get("/r/" + f.repo + "/requests/" + reqID) | ||
| 1093 | if code != 200 { | ||
| 1094 | t.Fatalf("request page: %d", code) | ||
| 1095 | } | ||
| 1096 | if !strings.Contains(body, "no longer merges into main cleanly") || !strings.Contains(body, "code.txt") { | ||
| 1097 | t.Fatalf("the page did not report the conflict:\n%s", body) | ||
| 1098 | } | ||
| 1099 | // The requirement is on the page, not left to memory. | ||
| 1100 | if !strings.Contains(body, "0 of 1 required approval(s)") { | ||
| 1101 | t.Fatalf("the page does not state how many approvals are needed:\n%s", body) | ||
| 1102 | } | ||
| 1103 | |||
| 1104 | // A branch that does merge says so, so silence means "could not check". | ||
| 1105 | clean, cleanHead := f.cloneWithChange(t, map[string]string{"other.txt": "new file\n"}, "clean") | ||
| 1106 | if _, errs, err := f.git(clean, "push", f.sshURL(f.repo), "HEAD:refs/heads/clean"); err != nil { | ||
| 1107 | t.Fatalf("clean push: %s", errs) | ||
| 1108 | } | ||
| 1109 | const cleanID = "01M5JR2JK29KKMAA1T9MDNTR02" | ||
| 1110 | if err := meta.apply(map[string]string{ | ||
| 1111 | "requests/" + cleanID + "/request.md": requestFile("main", cleanHead, "keenan", f.ownerID, "clean change\n"), | ||
| 1112 | }, "clean request"); err != nil { | ||
| 1113 | t.Fatalf("clean request push: %v", err) | ||
| 1114 | } | ||
| 1115 | if _, body := f.get("/r/" + f.repo + "/requests/" + cleanID); !strings.Contains(body, "Merges into main cleanly.") { | ||
| 1116 | t.Fatalf("a mergeable branch was not reported as clean:\n%s", body) | ||
| 1117 | } | ||
| 1118 | |||
| 1119 | // The CLI asks the same question from the clone, and answers it while the head | ||
| 1120 | // is reachable. | ||
| 1121 | clone := filepath.Join(t.TempDir(), "clone") | ||
| 1122 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil { | ||
| 1123 | t.Fatalf("clone: %s", errs) | ||
| 1124 | } | ||
| 1125 | out, _, err := f.cli(clone, "request", "show", reqID) | ||
| 1126 | if err != nil { | ||
| 1127 | t.Fatalf("request show: %v", err) | ||
| 1128 | } | ||
| 1129 | if !strings.Contains(out, "CONFLICT with main in code.txt") { | ||
| 1130 | t.Fatalf("the CLI did not report the conflict:\n%s", out) | ||
| 1131 | } | ||
| 1132 | |||
| 1133 | // With the branch gone, the head is unreachable, and neither side claims | ||
| 1134 | // anything: a page that says "clean" for a commit it could not load is worse | ||
| 1135 | // than one that stays quiet. | ||
| 1136 | if _, errs, err := f.git(clone, "push", f.sshURL(f.repo), ":refs/heads/feature"); err != nil { | ||
| 1137 | t.Fatalf("delete branch: %s", errs) | ||
| 1138 | } | ||
| 1139 | fresh := filepath.Join(t.TempDir(), "fresh") | ||
| 1140 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), fresh); err != nil { | ||
| 1141 | t.Fatalf("fresh clone: %s", errs) | ||
| 1142 | } | ||
| 1143 | out, _, err = f.cli(fresh, "request", "show", reqID) | ||
| 1144 | if err != nil { | ||
| 1145 | t.Fatalf("request show: %v", err) | ||
| 1146 | } | ||
| 1147 | if !strings.Contains(out, "cannot check") || !strings.Contains(out, "git fetch") { | ||
| 1148 | t.Fatalf("a clone without the head claimed something about the merge:\n%s", out) | ||
| 1149 | } | ||
| 1150 | // A request whose head was never pushed is the state the page must stay quiet | ||
| 1151 | // about: the commit is not in the repository, so there is no merge to report. | ||
| 1152 | const ghostID = "01M5JR2JK29KKMAA1T9MDNTR03" | ||
| 1153 | if err := meta.apply(map[string]string{ | ||
| 1154 | "requests/" + ghostID + "/request.md": requestFile("main", | ||
| 1155 | "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "keenan", f.ownerID, "ghost head\n"), | ||
| 1156 | }, "ghost request"); err != nil { | ||
| 1157 | t.Fatalf("ghost request push: %v", err) | ||
| 1158 | } | ||
| 1159 | if code, body := f.get("/r/" + f.repo + "/requests/" + ghostID); code != 200 || | ||
| 1160 | strings.Contains(body, "no longer merges") || strings.Contains(body, "Merges into main cleanly.") { | ||
| 1161 | t.Fatalf("the page claimed a merge result it could not compute (%d):\n%s", code, body) | ||
| 1162 | } | ||
| 1163 | } | ||
| 1164 | |||
| 1054 | 1165 | func (f *fixture) serverIssues(t *testing.T) string { | |
| 1055 | 1166 | t.Helper() | |
| 1056 | 1167 | out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | |