A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 471902dd14

471902dd1405f81f8414ea60ae8526cda92387cc

parent: ac6714f569

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T03:49:24Z

Surface mirror status in repo show and the web repo page

repo show gains a mirrors section (direction, URL, pending, last sync,
last error) for repo admins; the web repo header shows an admin-only
mirror line with a sync-error state. Tokens are never included.

Closes #32
e2e/mirror_test.go +40 −1
@@ -1,6 +1,7 @@
11 package e2e
22
33 import (
4 "encoding/json"
45 "net/http/cgi"
56 "net/http/httptest"
67 "os"
@@ -52,7 +53,7 @@ func waitFor(t *testing.T, what string, cond func() bool) {
5253
5354 func TestMirrors(t *testing.T) {
5455 t.Setenv("GITBAY_MIRROR_TICK", "200ms")
55 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n")
56 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n[web]\nmode = \"accounts\"\n")
5657 aliceKey := inst.newKey(t, "alice")
5758 bobKey := inst.newKey(t, "bob")
5859 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
@@ -91,6 +92,40 @@ func TestMirrors(t *testing.T) {
9192 t.Fatalf("mirror list after sync: %s", out)
9293 }
9394
95 // ---- status surfacing: repo show carries mirrors for admins only.
96 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
97 if !strings.Contains(out, `"mirrors":[`) || !strings.Contains(out, `"last_sync":"`) ||
98 strings.Contains(out, "token") {
99 t.Fatalf("repo show missing mirror status: %s", out)
100 }
101 out, _, _ = inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json")
102 if strings.Contains(out, `"mirrors"`) {
103 t.Fatalf("repo show leaked mirrors to non-admin: %s", out)
104 }
105
106 // The web repo page shows the mirror line to the admin, not to visitors.
107 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
108 if code != 0 {
109 t.Fatalf("web login: %s", errOut)
110 }
111 var loginEnv struct {
112 Data struct {
113 URL string `json:"url"`
114 } `json:"data"`
115 }
116 json.Unmarshal([]byte(out), &loginEnv)
117 loginPath := loginEnv.Data.URL[strings.Index(loginEnv.Data.URL, "/login"):]
118 browser := newBrowser(t)
119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120 t.Fatalf("login: %d", status)
121 }
122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "mirrors to") {
123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124 }
125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "mirrors to") {
126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127 }
128
94129 // ---- pull mirror: local repo follows the remote and refuses pushes.
95130 srcURL, srcBare := gitHTTPRemote(t)
96131 seed := t.TempDir()
@@ -144,6 +179,10 @@ func TestMirrors(t *testing.T) {
144179 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json")
145180 return strings.Contains(out, `"last_error":"git push`)
146181 })
182 // The failure is visible on the admin's web repo page.
183 if _, body := browserGet(t, browser, inst.base()+"/alice/follow"); !strings.Contains(body, "sync error:") {
184 t.Fatalf("admin repo page missing sync error:\n%s", body)
185 }
147186 }
148187
149188 func TestMirrorSSRFGuard(t *testing.T) {
internal/control/repo.go +37 −8
@@ -226,14 +226,22 @@ func runRepoShow(c *Ctx, args []string) int {
226226 if code >= 0 {
227227 return code
228228 }
229 type mirrorOut struct {
230 Direction string `json:"direction"`
231 URL string `json:"url"`
232 Pending bool `json:"pending"`
233 LastSync string `json:"last_sync,omitempty"`
234 LastError string `json:"last_error,omitempty"`
235 }
229236 type out struct {
230 Path string `json:"path"`
231 Description string `json:"description,omitempty"`
232 Visibility string `json:"visibility"`
233 DefaultBranch string `json:"default_branch"`
234 ProtectedBranches []string `json:"protected_branches,omitempty"`
235 Archived bool `json:"archived,omitempty"`
236 Topics []string `json:"topics,omitempty"`
237 Path string `json:"path"`
238 Description string `json:"description,omitempty"`
239 Visibility string `json:"visibility"`
240 DefaultBranch string `json:"default_branch"`
241 ProtectedBranches []string `json:"protected_branches,omitempty"`
242 Archived bool `json:"archived,omitempty"`
243 Topics []string `json:"topics,omitempty"`
244 Mirrors []mirrorOut `json:"mirrors,omitempty"`
237245 }
238246 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
239247 topics, err := c.Store.ListTopics(repo.ID)
@@ -241,7 +249,18 @@ func runRepoShow(c *Ctx, args []string) int {
241249 return c.fail(protocol.ExitFailure, "%v", err)
242250 }
243251 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches,
244 repo.Settings.Archived, topics}
252 repo.Settings.Archived, topics, nil}
253 // Mirror status is admin-only, like repo mirror list. The token never
254 // leaves the server.
255 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
256 ms, err := c.Store.ListMirrors(repo.ID)
257 if err != nil {
258 return c.fail(protocol.ExitFailure, "%v", err)
259 }
260 for _, m := range ms {
261 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
262 }
263 }
245264 return c.emit(d, func(w io.Writer) {
246265 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
247266 if d.Archived {
@@ -257,6 +276,16 @@ func runRepoShow(c *Ctx, args []string) int {
257276 if len(d.ProtectedBranches) > 0 {
258277 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
259278 }
279 for _, m := range d.Mirrors {
280 status := "ok"
281 if m.Pending {
282 status = "pending"
283 }
284 if m.LastError != "" {
285 status = "error: " + m.LastError
286 }
287 fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
288 }
260289 })
261290 }
262291
internal/httpd/web.go +33 −1
@@ -218,6 +218,25 @@ type repoPage struct {
218218 Pinned bool // by the viewer
219219 HasWiki bool
220220 Host string
221 Mirrors []mirrorLine // repo admins only
222}
223
224// mirrorLine is the admin-only mirror status shown in the repo header.
225// It carries no credentials: URL host/path only, sync time, and error.
226type mirrorLine struct {
227 Direction string
228 Target string // URL without the scheme
229 Synced string
230 Error string
231}
232
233// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
234// readable "2026-08-25 03:39 UTC".
235func syncedAt(ts string) string {
236 if len(ts) < 16 {
237 return ts
238 }
239 return ts[:10] + " " + ts[11:16] + " UTC"
221240 }
222241
223242 // repoFor resolves the repo for a web request; false means 404 was sent.
@@ -232,8 +251,8 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
232251 }
233252 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
234253 ok := err == nil
254 grant := ""
235255 if ok {
236 grant := ""
237256 if viewer.ID != 0 {
238257 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
239258 }
@@ -251,7 +270,20 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
251270 if viewer.ID != 0 {
252271 pinned = s.st.IsPinned(viewer.ID, repo.ID)
253272 }
273 var mirrors []mirrorLine
274 if viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) {
275 ms, _ := s.st.ListMirrors(repo.ID)
276 for _, m := range ms {
277 mirrors = append(mirrors, mirrorLine{
278 Direction: m.Direction,
279 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
280 Synced: syncedAt(m.LastSync),
281 Error: m.LastError,
282 })
283 }
284 }
254285 return repoPage{
286 Mirrors: mirrors,
255287 Site: s.siteName(),
256288 Viewer: viewer.Username,
257289 Pinned: pinned,
internal/web/static/style.css +2
@@ -530,6 +530,8 @@ pre.diff .meta { color: var(--muted); }
530530 .chip.label { font-weight: 500; }
531531 .chip.topic { --chip: var(--accent); }
532532 .repohead .topics { margin: 0 0 var(--sp-2); }
533.repohead .mirrorline { margin: 0 0 var(--sp-2); }
534.mirrorerr { color: var(--bad); }
533535 .badge-verified { --chip: var(--ok); }
534536 .badge-unsigned { --chip: var(--neutral); }
535537 .badge-signed_unknown_key { --chip: var(--warn); }
internal/web/templates/layout.html +2 −1
@@ -32,7 +32,8 @@
3232 <h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1>
3333 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
3434 {{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
35<nav class="tabs">
35{{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} {{.Target}}{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
36{{end}}<nav class="tabs">
3637 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
3738 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
3839 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>