Commit ce9df03eda

ce9df03eda849f4d70b089eb2d3a3b36ac1d7378

parent: 7dd9852ea5

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 05:26 UTC

web: a merged MR names its merged head and a deleted source branch; both clone URLs

The MR sidebar reads "merged at <sha>" once merged and "head <sha>"
before, and marks a same-repository source branch that no longer
exists. The repository home shows the SSH clone URL beside HTTPS.

Ref #182
e2e/mrweb_test.go +7
@@ -155,6 +155,13 @@ func TestMRWebReviewLoop(t *testing.T) {
155155 if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil {
156156 t.Fatal("merged content missing from main")
157157 }
158 // A merged MR shows its merged head, and marks a source branch that
159 // no longer exists.
160 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "--delete", "feature")
161 _, body = browserGet(t, alice, mrURL)
162 if !strings.Contains(body, "merged at") || !strings.Contains(body, "branch deleted") {
163 t.Fatalf("merged MR sidebar after the branch was deleted:\n%s", body)
164 }
158165
159166 // Readers get no controls, and a forged POST is refused by the command.
160167 _, anon := browserGet(t, newBrowser(t), mrURL)
e2e/web_test.go +4
@@ -85,6 +85,10 @@ func TestWebUI(t *testing.T) {
8585 if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") {
8686 t.Fatalf("repo home: %d\n%s", status, body)
8787 }
88 // Both clone URLs: SSH for anyone with a key, HTTPS for reading.
89 if !strings.Contains(body, "git clone ssh://git@gitbay.test:") || !strings.Contains(body, "/alice/site.git</code> · <code>git clone https://gitbay.test/alice/site.git</code>") {
90 t.Fatalf("clone URLs missing:\n%s", body)
91 }
8892 if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") {
8993 t.Fatalf("README not rendered:\n%s", body)
9094 }
internal/httpd/mrpage_test.go +1
@@ -33,6 +33,7 @@ type mrPageData struct {
3333 Notice string
3434 DetachedThreads []diffThread
3535 Gates *control.GatesOut
36 SourceGone bool
3637}
3738
3839func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
internal/httpd/web.go +59 −28
@@ -243,17 +243,20 @@ type repoPage struct {
243243 Repo store.Repo
244244 Ref string
245245 CloneURL string
246 Dir string
247 Tab string // active tab in the repo header
248 Topics []string
249 Pinned bool // by the viewer
250 Marked bool // bookmarked by the viewer
251 Watch string // the viewer's watch state: watching, muted, or ""
252 HasWiki bool
253 Host string
254 Mirrors []mirrorLine // repo admins only
255 CanAdmin bool // gates the settings tab
256 Feed string // Atom feed for this page, if it has one
246 // SSHCloneURL is the same repository over the SSH transport, which is
247 // the one a push needs.
248 SSHCloneURL string
249 Dir string
250 Tab string // active tab in the repo header
251 Topics []string
252 Pinned bool // by the viewer
253 Marked bool // bookmarked by the viewer
254 Watch string // the viewer's watch state: watching, muted, or ""
255 HasWiki bool
256 Host string
257 Mirrors []mirrorLine // repo admins only
258 CanAdmin bool // gates the settings tab
259 Feed string // Atom feed for this page, if it has one
257260 // OpenIssues and OpenMRs are the counts on the header tabs.
258261 OpenIssues int
259262 OpenMRs int
@@ -331,22 +334,23 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
331334 }
332335 openIssues, openMRs := s.st.OpenCounts(repo.ID)
333336 return repoPage{
334 basePage: s.baseFor(viewer),
335 CanAdmin: canAdmin,
336 Mirrors: mirrors,
337 Pinned: pinned,
338 Marked: marked,
339 Watch: watch,
340 HasWiki: s.hasWiki(repo),
341 Host: s.cfg.SiteHost(),
342 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
343 Repo: repo,
344 Ref: ref,
345 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
346 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
347 Topics: topics,
348 OpenIssues: openIssues,
349 OpenMRs: openMRs,
337 basePage: s.baseFor(viewer),
338 CanAdmin: canAdmin,
339 Mirrors: mirrors,
340 Pinned: pinned,
341 Marked: marked,
342 Watch: watch,
343 HasWiki: s.hasWiki(repo),
344 Host: s.cfg.SiteHost(),
345 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
346 Repo: repo,
347 Ref: ref,
348 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
349 SSHCloneURL: s.sshCloneURL(repo),
350 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
351 Topics: topics,
352 OpenIssues: openIssues,
353 OpenMRs: openMRs,
350354 }, true
351355}
352356
@@ -1951,9 +1955,26 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19511955 StackedOn *store.MR
19521956 Stacked []store.MR
19531957 Gates *control.GatesOut
1958 SourceGone bool
19541959 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
19551960 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1956 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates})
1961 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1962 sourceGone(p, m)})
1963}
1964
1965// sourceGone reports whether an MR's source branch no longer exists: the
1966// push hook marks a deleted branch on an open MR, and a merged or closed
1967// one is checked here. A fork's branch lives in another repository and
1968// is left to the recorded state.
1969func sourceGone(p repoPage, m store.MR) bool {
1970 if m.State == "source_gone" {
1971 return true
1972 }
1973 if m.SourceRepoID != p.Repo.ID {
1974 return false
1975 }
1976 _, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)
1977 return err != nil
19571978}
19581979
19591980func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
@@ -2007,3 +2028,13 @@ type reviewRow struct {
20072028 store.MRReview
20082029 Counts bool
20092030}
2031
2032// sshCloneURL is the SSH clone URL for a repository, with the port only
2033// when it is not the default.
2034func (s *Server) sshCloneURL(repo store.Repo) string {
2035 host := s.cfg.SiteHost()
2036 if s.cfg.SSH.Port != 22 {
2037 host += ":" + strconv.Itoa(s.cfg.SSH.Port)
2038 }
2039 return "ssh://git@" + host + "/" + repo.Path() + ".git"
2040}
internal/web/templates/mr.html +1 −1
@@ -162,7 +162,7 @@
162162 <div class="grp">
163163 <h2>Source</h2>
164164 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p>
165 <p class="row none">into <code>{{.MR.TargetRef}}</code> at <code>{{short .MR.HeadSHA}}</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>
166166 </div>
167167 {{if .MR.Milestone}}<div class="grp">
168168 <h2>Milestone</h2>
internal/web/templates/tree.html +1 −1
@@ -10,7 +10,7 @@
1010 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
1111 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
1212</div>
13{{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}}
13{{if .Entries}}<p class="clone">Clone: <code>git clone {{.SSHCloneURL}}</code> · <code>git clone {{.CloneURL}}</code></p>{{end}}
1414{{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="facts">
1515 <p class="counts">
1616 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a>