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/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"),