Commit 934524888e

934524888e65cf71d9021703bdd56a1485a43c7d

parent: f82dd00118

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 16:19 UTC

web: merge request page states its resolution and explains an empty diff

The title line now says who opened, merged, or closed the request and
when, matching the issue page's pattern; the aside folds Target and
Source into one Source and target group with the base SHA alongside
the head, and reorders to Review, Merge, Merge gates, Checks, Reviews,
Reviewers, Source and target, Milestone. A merge request whose diff is
empty because its head is already an ancestor of the target (a fast-
forward done outside the request) says so instead of showing a bare
"0 files changed". The conversation column is wrapped in .prose.

mrpage_test.go's mrPageData mirror and TestMRHeaderByState follow the
struct and copy changes.

Ref #218
e2e/mrweb_test.go +38 −3
@@ -145,10 +145,10 @@ func TestMRWebReviewLoop(t *testing.T) {
145145 t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0])
146146 }
147147 _, body = browserGet(t, alice, mrURL)
148 if strings.Contains(body, "wants to merge") {
149 t.Fatalf("merged MR still wants to merge:\n%s", body)
148 if strings.Contains(body, "opened by") {
149 t.Fatalf("merged MR still says it was opened:\n%s", body)
150150 }
151 if !strings.Contains(body, ">alice</a> merged") {
151 if !strings.Contains(body, "merged by <a href=\"/alice\">alice</a>") {
152152 t.Fatalf("merged MR does not name the merger:\n%s", body)
153153 }
154154 mustGit(t, dir, env, "pull", "-q", "origin", "main")
@@ -353,3 +353,38 @@ func (i *instance) mrThreads(t *testing.T, key, repo, n string) []mrThread {
353353 }
354354 return env.Data
355355}
356
357// TestMRDiffEmptyExplained: a merge request whose head was fast-forwarded
358// into the target outside the request shows why its diff is empty.
359func TestMRDiffEmptyExplained(t *testing.T) {
360 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
361 key := inst.newKey(t, "alice")
362 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--email", "alice@example.test", "--verified")
363 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
364 t.Fatalf("repo create: %s", errOut)
365 }
366 env := inst.gitEnv(key)
367 work := t.TempDir()
368 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
369 dir := filepath.Join(work, "w")
370 os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644)
371 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
372 mustGit(t, dir, env, "add", ".")
373 mustGit(t, dir, env, "commit", "-q", "-m", "base")
374 mustGit(t, dir, env, "push", "-q", "origin", "main")
375
376 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
377 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644)
378 mustGit(t, dir, env, "add", ".")
379 mustGit(t, dir, env, "commit", "-q", "-m", "one")
380 mustGit(t, dir, env, "push", "-q", "origin", "feature")
381 if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app", "--source", "feature", "--target", "main", "--title", "one"); code != 0 {
382 t.Fatal(errOut)
383 }
384 mustGit(t, dir, env, "push", "-q", "origin", "feature:main")
385 _, body := inst.get(t, "/alice/app/mrs/1?view=diff")
386 if !strings.Contains(body, "No changes between the source and target.") ||
387 !strings.Contains(body, "already merged or fast-forwarded into <code>main</code>") {
388 t.Fatalf("empty diff unexplained:\n%s", body)
389 }
390}
internal/httpd/mrpage_test.go +10 −8
@@ -34,6 +34,8 @@ type mrPageData struct {
3434 DetachedThreads []diffThread
3535 Gates *control.GatesOut
3636 SourceGone bool
37 HeadMerged bool
38 Base string
3739}
3840
3941func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
@@ -60,12 +62,12 @@ func testMR(state string) store.MR {
6062 HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
6163}
6264
63// The header states what happened to the MR. "wants to merge" is only true
64// while it is still open.
65// The header states what happened to the MR: who opened, merged, or closed
66// it, and when.
6567func TestMRHeaderByState(t *testing.T) {
6668 open := renderMR(t, testMR("open"), nil, nil)
67 if !strings.Contains(open, "wants to merge") {
68 t.Errorf("open MR does not say wants to merge:\n%s", open)
69 if !strings.Contains(open, "opened by") {
70 t.Errorf("open MR does not say who opened it:\n%s", open)
6971 }
7072
7173 merged := testMR("merged")
@@ -76,21 +78,21 @@ func TestMRHeaderByState(t *testing.T) {
7678 t.Errorf("merged header missing %q:\n%s", want, out)
7779 }
7880 }
79 if strings.Contains(out, "wants to merge") {
80 t.Errorf("merged MR still wants to merge:\n%s", out)
81 if strings.Contains(out, "opened by") {
82 t.Errorf("merged MR still says it was opened:\n%s", out)
8183 }
8284
8385 closed := testMR("closed")
8486 closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc"
8587 out = renderMR(t, closed, nil, nil)
86 if !strings.Contains(out, "without merging") || strings.Contains(out, "wants to merge") {
88 if !strings.Contains(out, "without merging") || strings.Contains(out, "opened by") {
8789 t.Errorf("closed header:\n%s", out)
8890 }
8991
9092 // Imports and pre-0029 merges carry no stamp; the wording drops the
9193 // claim rather than inventing a time.
9294 out = renderMR(t, testMR("merged"), nil, nil)
93 if strings.Contains(out, "wants to merge") || strings.Contains(out, " on 20") {
95 if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") {
9496 t.Errorf("unstamped merged header:\n%s", out)
9597 }
9698}
internal/httpd/web.go +13 −1
@@ -1868,6 +1868,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
18681868 files, diffTruncated = parseDiff(patch), truncated
18691869 }
18701870 }
1871 // The head is already reachable from the target, so the diff is empty
1872 // by construction rather than because nothing changed.
1873 headMerged := false
1874 if len(files) == 0 && m.HeadSHA != "" {
1875 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
1876 if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
1877 headMerged = ok
1878 }
1879 }
1880 }
18711881 md := s.ugcFor(r, p.Repo)
18721882 canWrite := s.canWriteRepo(r, p.Repo)
18731883 var detachedThreads []diffThread
@@ -1963,10 +1973,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19631973 Stacked []store.MR
19641974 Gates *control.GatesOut
19651975 SourceGone bool
1976 HeadMerged bool
1977 Base string
19661978 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
19671979 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
19681980 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1969 sourceGone(p, m)})
1981 sourceGone(p, m), headMerged, base})
19701982}
19711983
19721984// sourceGone reports whether an MR's source branch no longer exists: the
internal/web/templates/mr.html +39 −49
@@ -1,20 +1,10 @@
1{{define "mrrange"}}<code>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}}</code> into <code>{{.TargetRef}}</code>{{end}}
21{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
32{{define "content"}}
43{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
54<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
6<p class="issuemeta">{{if .MR.Draft}}<span class="chip chip-neutral">draft</span> {{end}}<span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
7{{if and (eq .MR.State "merged") .MR.MergedAt}}
8 {{if .MR.MergedBy}}<a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> merged{{else}}Merged{{end}}
9 {{template "mrrange" .MR}} on {{when .MR.MergedAt}}
10{{else if and (eq .MR.State "closed") .MR.ClosedAt}}
11 {{if .MR.ClosedBy}}<a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> closed this{{else}}Closed{{end}}
12 without merging {{template "mrrange" .MR}} on {{when .MR.ClosedAt}}
13{{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}
14 {{template "mrrange" .MR}}
15{{else}}
16 <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{template "mrrange" .MR}}
17{{end}}</p>
5<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}}
6 {{if eq .MR.State "merged"}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> on {{when .MR.MergedAt}}{{else if eq .MR.State "closed"}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> on {{when .MR.ClosedAt}}{{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}}
7 · <code>{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
188{{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}}
199{{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}}
2010
@@ -30,6 +20,7 @@
3020</nav>
3121
3222{{if eq .View "conversation"}}
23<div class="prose">
3324{{if .CanEdit}}<details class="editbox"><summary>Edit</summary>
3425<form method="post" action="{{$base}}/edit" class="commentform">
3526<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p>
@@ -55,6 +46,7 @@
5546<p><button type="submit" class="btn">Comment</button></p>
5647</form>
5748{{end}}
49</div>
5850
5951{{else if eq .View "commits"}}
6052{{if gt .CommitsTotal (len .Commits)}}<p class="meta">first {{len .Commits}} of {{.CommitsTotal}} commits; the rest are in the branch</p>{{end}}
@@ -74,9 +66,10 @@
7466</ul>
7567
7668{{else}}
77<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
69{{if .DiffFiles}}<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
7870{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}}
7971{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}}
72{{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}}
8073{{end}}
8174
8275</div>
@@ -113,38 +106,6 @@
113106 </form>
114107 </div>
115108 {{end}}
116 {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
117 <div class="grp">
118 <h2>Target</h2>
119 <form method="post" action="{{$base}}/retarget" class="actions">
120 <label class="none" for="target">Branch</label>
121 <select id="target" name="target">
122 {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}}
123 </select>
124 <button type="submit" class="btn">Retarget</button>
125 </form>
126 <p class="row none">Retargeting stales existing reviews.</p>
127 </div>
128 {{end}}
129 <div class="grp">
130 <h2>Reviewers</h2>
131 {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
132 {{else}}<p class="none">nobody yet</p>{{end}}
133 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
134 <form method="post" action="{{$base}}/review-request" class="actions">
135 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated">
136 <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove">
137 <button type="submit" class="btn">Apply</button>
138 </form>
139 {{end}}
140 </div>
141 <div class="grp">
142 <h2>Reviews</h2>
143 {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}{{if not .Counts}} <span class="chip chip-neutral" title="This reviewer has no write access, so the merge gates do not count it">advisory</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p>
144 {{else}}<p class="none">none yet</p>{{end}}
145 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
146 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
147 </div>
148109 {{with .Gates}}<div class="grp">
149110 <h2>Merge gates</h2>
150111 {{if .Unmet}}{{range .Unmet}}<p class="row"><span class="dot bad"></span>{{.}}</p>{{end}}
@@ -160,9 +121,38 @@
160121 {{else}}<p class="none">No checks reported</p>{{end}}
161122 </div>
162123 <div class="grp">
163 <h2>Source</h2>
164 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p>
165 <p class="row none">into <code>{{.MR.TargetRef}}</code> · {{if eq .MR.State "merged"}}merged at{{else}}head{{end}} <code>{{short .MR.HeadSHA}}</code>{{if .SourceGone}} · <span class="chip chip-neutral">branch deleted</span>{{end}}</p>
124 <h2>Reviews</h2>
125 {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}{{if not .Counts}} <span class="chip chip-neutral" title="This reviewer has no write access, so the merge gates do not count it">advisory</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p>
126 {{else}}<p class="none">none yet</p>{{end}}
127 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
128 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
129 </div>
130 <div class="grp">
131 <h2>Reviewers</h2>
132 {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
133 {{else}}<p class="none">nobody yet</p>{{end}}
134 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
135 <form method="post" action="{{$base}}/review-request" class="actions">
136 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated">
137 <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove">
138 <button type="submit" class="btn">Apply</button>
139 </form>
140 {{end}}
141 </div>
142 <div class="grp">
143 <h2>Source and target</h2>
144 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
145 <p class="row none">{{if eq .MR.State "merged"}}merged at{{else}}head{{end}} <code>{{short .MR.HeadSHA}}</code>{{if .Base}} · base <code>{{short .Base}}</code>{{end}}{{if .SourceGone}} · <span class="chip chip-neutral">branch deleted</span>{{end}}</p>
146 {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
147 <form method="post" action="{{$base}}/retarget" class="actions">
148 <label class="none" for="target">Branch</label>
149 <select id="target" name="target">
150 {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}}
151 </select>
152 <button type="submit" class="btn">Retarget</button>
153 </form>
154 <p class="row none">Retargeting stales existing reviews.</p>
155 {{end}}
166156 </div>
167157 {{if .MR.Milestone}}<div class="grp">
168158 <h2>Milestone</h2>