A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 8e64f8208b

8e64f8208b18dc40fb5c27c873802282e722255e

parent: f65bfc3c4e

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T03:42:14Z

web: repository settings for admins, and a visibility command

A settings page groups the repo settings commands: description,
website, topics, visibility, git:// exposure, the four merge gates,
branch protection, and archiving. Each control dispatches the command
the CLI runs, so validation and audit entries keep one implementation,
and a refusal returns the command's own message.

Visibility had no command anywhere, so it lands over SSH first: repo
settings visibility <owner/name> public|private, admin only, audited.
Going private also drops git:// exposure, which would otherwise outlive
the change.

Deleting and transferring stay on the CLI: both want a typed
confirmation rather than a button.

Ref #35
e2e/settingsweb_test.go added +94
@@ -0,0 +1,94 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// TestRepoSettingsWeb drives the settings page: each control runs the
10// command the CLI runs, so repo show and settings show are the check.
11func TestRepoSettingsWeb(t *testing.T) {
12 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
13 aliceKey := inst.newKey(t, "alice")
14 bobKey := inst.newKey(t, "bob")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
18 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
19 t.Fatalf("repo create: %s", errOut)
20 }
21
22 alice := inst.login(t, aliceKey)
23 set := inst.base() + "/alice/app/settings"
24
25 // Only admins reach the page, and only they see the tab.
26 if _, body := browserGet(t, alice, inst.base()+"/alice/app"); !strings.Contains(body, "/alice/app/settings") {
27 t.Fatalf("no settings tab for the owner:\n%s", body)
28 }
29 if status, _ := browserGet(t, inst.login(t, bobKey), set); status != 403 && status != 404 {
30 t.Fatalf("reader reached settings: %d", status)
31 }
32
33 post := func(v url.Values) string {
34 t.Helper()
35 status, body := browserPost(t, alice, set, v)
36 if status != 200 {
37 t.Fatalf("settings post %v: %d", v, status)
38 }
39 return body
40 }
41
42 post(url.Values{"field": {"description"}, "description": {"a fine tool"}})
43 post(url.Values{"field": {"website"}, "website": {"https://tool.example"}})
44 post(url.Values{"field": {"topics"}, "add": {"cli forge"}})
45 post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}})
46 post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}})
47 post(url.Values{"field": {"protect"}, "branch": {"main"}})
48
49 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
50 for _, want := range []string{"a fine tool", "https://tool.example", `"cli"`, `"forge"`} {
51 if !strings.Contains(out, want) {
52 t.Fatalf("repo show missing %q:\n%s", want, out)
53 }
54 }
55 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json")
56 for _, want := range []string{`"require_checks":true`, `"require_approvals":2`, `"main"`} {
57 if !strings.Contains(out, want) {
58 t.Fatalf("settings show missing %q:\n%s", want, out)
59 }
60 }
61
62 // Visibility is a new command; the web form drives it both ways.
63 post(url.Values{"field": {"visibility"}, "visibility": {"private"}})
64 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); !strings.Contains(out, `"visibility":"private"`) {
65 t.Fatalf("not private:\n%s", out)
66 }
67 // A private repo disappears from anonymous surfaces.
68 if status, _ := inst.get(t, "/alice/app"); status != 404 {
69 t.Fatalf("private repo still public: %d", status)
70 }
71 post(url.Values{"field": {"visibility"}, "visibility": {"public"}})
72 if status, _ := inst.get(t, "/alice/app"); status != 200 {
73 t.Fatalf("public repo not restored: %d", status)
74 }
75
76 // Archiving is reversible from the page; unchecking the box unarchives.
77 post(url.Values{"field": {"archive"}, "archive": {"on"}})
78 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); !strings.Contains(out, `"archived":true`) {
79 t.Fatalf("not archived:\n%s", out)
80 }
81 post(url.Values{"field": {"archive"}})
82 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"archived":true`) {
83 t.Fatalf("still archived:\n%s", out)
84 }
85
86 // Unprotecting works, and a refusal surfaces the command's message.
87 post(url.Values{"field": {"unprotect"}, "branch": {"main"}})
88 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) {
89 t.Fatalf("branch still protected:\n%s", out)
90 }
91 if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) {
92 t.Fatalf("bad website accepted:\n%s", body)
93 }
94}
internal/control/repo.go +31
@@ -48,6 +48,8 @@ func init() {
4848 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
4949 register(Command{Path: []string{"repo", "settings", "description"},
5050 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
51 register(Command{Path: []string{"repo", "settings", "visibility"},
52 Summary: "set repository visibility: repo settings visibility <owner/name> public|private", Run: runSetVisibility})
5153 register(Command{Path: []string{"repo", "settings", "website"},
5254 Summary: "set the repository website: repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite})
5355 register(Command{Path: []string{"repo", "settings", "git-daemon"},
@@ -532,6 +534,35 @@ func runSetWebsite(c *Ctx, args []string) int {
532534 })
533535 }
534536
537func runSetVisibility(c *Ctx, args []string) int {
538 if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
539 return c.fail(protocol.ExitUsage, "usage: repo settings visibility <owner/name> public|private")
540 }
541 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
542 if code >= 0 {
543 return code
544 }
545 if repo.Visibility == args[1] {
546 return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) {
547 fmt.Fprintf(w, "%s is already %s\n", repo.Path(), args[1])
548 })
549 }
550 if err := c.Store.SetRepoVisibility(repo.ID, args[1]); err != nil {
551 return c.fail(protocol.ExitFailure, "%v", err)
552 }
553 // Going private takes the repository off every anonymous surface, so
554 // git:// exposure cannot outlive the change.
555 if args[1] == "private" && repo.Settings.GitDaemon {
556 s := repo.Settings
557 s.GitDaemon = false
558 c.Store.SetRepoSettings(repo.ID, s)
559 }
560 c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": args[1]})
561 return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) {
562 fmt.Fprintf(w, "%s is now %s\n", repo.Path(), args[1])
563 })
564}
565
535566 func runGitDaemon(c *Ctx, args []string) int {
536567 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
537568 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
internal/httpd/routes.go +4
@@ -120,6 +120,10 @@ func (s *Server) Routes() []Route {
120120 Handler: s.checkOrigin(s.requireUser(s.issueAssignSubmit))},
121121 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/milestone", Mutating: true,
122122 Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))},
123 Route{Method: "GET", Pattern: "/{owner}/{repo}/settings",
124 Handler: s.requireUser(s.settingsForm)},
125 Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true,
126 Handler: s.checkOrigin(s.requireUser(s.settingsSubmit))},
123127 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/new",
124128 Handler: s.requireUser(s.mrCreateForm)},
125129 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/new", Mutating: true,
internal/httpd/settings.go added +115
@@ -0,0 +1,115 @@
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "strings"
8
9 "gitbay.org/gitbay/internal/gitutil"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// Repository settings for repo admins. Every control dispatches the
14// command the CLI runs; the page only groups them. Destructive lifecycle
15// — delete and transfer — stays on the CLI, where a typed confirmation
16// is the norm.
17
18type settingsPage struct {
19 repoPage
20 Topics []string
21 Branches []gitutil.Ref
22 Notice string
23}
24
25func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
26 repo, ok := s.repoForUser(w, r, u, policyCanAdmin)
27 if !ok {
28 return
29 }
30 p, ok := s.repoFor(w, r, "")
31 if !ok {
32 return
33 }
34 p.Tab = "settings"
35 topics, _ := s.st.ListTopics(repo.ID)
36 branches, _ := gitutil.Refs(p.Dir, "heads")
37 s.render(w, "settings.html", settingsPage{
38 repoPage: p, Topics: topics, Branches: branches,
39 Notice: r.URL.Query().Get("e"),
40 })
41}
42
43func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg string) {
44 dest := fmt.Sprintf("/%s/%s/settings", r.PathValue("owner"), r.PathValue("repo"))
45 if msg != "" {
46 if len(msg) > 300 {
47 msg = msg[:300]
48 }
49 dest += "?e=" + url.QueryEscape(msg)
50 }
51 http.Redirect(w, r, dest, http.StatusSeeOther)
52}
53
54// settingsSubmit routes one form to its command. Keeping the mapping in
55// one place makes what the page can reach obvious.
56func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
57 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
58 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
59
60 var argv []string
61 switch r.FormValue("field") {
62 case "description":
63 argv = []string{"repo", "settings", "description", repo, v("description")}
64 case "website":
65 argv = []string{"repo", "settings", "website", repo, v("website")}
66 case "visibility":
67 argv = []string{"repo", "settings", "visibility", repo, v("visibility")}
68 case "git-daemon":
69 argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))}
70 case "require-checks":
71 argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))}
72 case "require-resolved":
73 argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))}
74 case "require-signed":
75 argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))}
76 case "require-approvals":
77 argv = []string{"repo", "settings", "require-approvals", repo, v("approvals")}
78 case "protect":
79 argv = []string{"repo", "settings", "protect", repo, v("branch")}
80 case "unprotect":
81 argv = []string{"repo", "settings", "unprotect", repo, v("branch")}
82 case "archive":
83 verb := "archive"
84 if v("archive") != "on" {
85 verb = "unarchive"
86 }
87 argv = []string{"repo", verb, repo}
88 case "topics":
89 if add := strings.Fields(v("add")); len(add) > 0 {
90 argv = append([]string{"repo", "topics", "add", repo}, add...)
91 } else if rm := strings.Fields(v("remove")); len(rm) > 0 {
92 argv = append([]string{"repo", "topics", "remove", repo}, rm...)
93 } else {
94 s.settingsRedirect(w, r, "name at least one topic")
95 return
96 }
97 default:
98 s.settingsRedirect(w, r, "unknown setting")
99 return
100 }
101
102 _, msg, ok := s.runControl(u, argv)
103 if ok {
104 msg = ""
105 }
106 s.settingsRedirect(w, r, msg)
107}
108
109// onOff normalises a checkbox to the on|off the commands take.
110func onOff(v string) string {
111 if v == "on" || v == "true" {
112 return "on"
113 }
114 return "off"
115}
internal/httpd/web.go +12 −5
@@ -231,6 +231,7 @@ type repoPage struct {
231231 HasWiki bool
232232 Host string
233233 Mirrors []mirrorLine // repo admins only
234 CanAdmin bool // gates the settings tab
234235 // OpenIssues and OpenMRs are the counts on the header tabs.
235236 OpenIssues int
236237 OpenMRs int
@@ -290,8 +291,9 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
290291 if viewer.ID != 0 {
291292 pinned = s.st.IsPinned(viewer.ID, repo.ID)
292293 }
294 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
293295 var mirrors []mirrorLine
294 if viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) {
296 if canAdmin {
295297 ms, _ := s.st.ListMirrors(repo.ID)
296298 for _, m := range ms {
297299 mirrors = append(mirrors, mirrorLine{
@@ -306,6 +308,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
306308 openIssues, openMRs := s.st.OpenCounts(repo.ID)
307309 return repoPage{
308310 basePage: s.baseFor(viewer),
311 CanAdmin: canAdmin,
309312 Mirrors: mirrors,
310313 Pinned: pinned,
311314 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
@@ -1259,10 +1262,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
12591262 s.render(w, "commit.html", struct {
12601263 repoPage
12611264 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1262 Parents []string
1263 Sig sigView
1264 Checks []store.CommitStatus
1265 DiffLines []diffLine
1265 Parents []string
1266 Sig sigView
1267 Checks []store.CommitStatus
1268 DiffLines []diffLine
12661269 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
12671270 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
12681271 gitutil.Parents(p.Dir, full), v, checks, lines})
@@ -1573,6 +1576,10 @@ func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
15731576 gitutil.Archive(p.Dir, ref, prefix, w)
15741577 }
15751578
1579func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1580 return policy.CanAdmin(u, repo, grant)
1581}
1582
15761583 func policyCanRead(u store.User, repo store.Repo, grant string) bool {
15771584 return policy.CanRead(u, repo, grant)
15781585 }
internal/store/repos.go +9
@@ -82,6 +82,15 @@ func (s *Store) RepoByPath(path string) (Repo, error) {
8282 return r, err
8383 }
8484
85// SetRepoVisibility switches a repository between public and private.
86func (s *Store) SetRepoVisibility(repoID int64, visibility string) error {
87 if visibility != "public" && visibility != "private" {
88 return fmt.Errorf("visibility must be public or private")
89 }
90 _, err := s.DB.Exec("UPDATE repos SET visibility = ? WHERE id = ?", visibility, repoID)
91 return err
92}
93
8594 func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
8695 raw, err := json.Marshal(settings)
8796 if err != nil {
internal/web/static/style.css +15
@@ -546,6 +546,21 @@ code.fullsha { color: var(--muted); overflow-wrap: anywhere; }
546546 padding: var(--sp-2) var(--sp-3);
547547 margin-bottom: var(--sp-4);
548548 }
549/* settings: one row per control, label then input then its own button */
550form.setform {
551 display: flex;
552 flex-wrap: wrap;
553 align-items: center;
554 gap: var(--sp-3);
555 padding: var(--sp-2) 0;
556 border-bottom: 1px solid var(--faint);
557}
558form.setform label { min-width: 12rem; color: var(--muted); font-size: var(--fs-2); }
559form.setform input[type="text"], form.setform select { flex: 1 1 16rem; }
560form.setform input[type="number"] { width: 5rem; }
561ul.protlist { list-style: none; padding: 0; margin: var(--sp-2) 0; }
562ul.protlist li { padding: var(--sp-1) 0; display: flex; gap: var(--sp-3); align-items: baseline; }
563
549564 /* branch picker on the new merge request form */
550565 p.branchpick {
551566 display: flex;
internal/web/templates/layout.html +1
@@ -70,6 +70,7 @@
7070 <a {{if eq $top "builds"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">Builds</a>
7171 <a {{if eq $top "releases"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">Releases</a>
7272 {{if field $ "HasWiki"}}<a {{if eq $top "wiki"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">Wiki</a>{{end}}
73 {{if field $ "CanAdmin"}}<a {{if eq $top "settings"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/settings">Settings</a>{{end}}
7374 </nav>
7475 </header>
7576 {{end}}
internal/web/templates/settings.html added +103
@@ -0,0 +1,103 @@
1{{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
4<h1>Settings</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
6
7<h2>Identity</h2>
8<form method="post" action="{{$base}}" class="setform">
9 <input type="hidden" name="field" value="description">
10 <label for="description">Description</label>
11 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings">
12 <button type="submit">Save</button>
13</form>
14<form method="post" action="{{$base}}" class="setform">
15 <input type="hidden" name="field" value="website">
16 <label for="website">Website</label>
17 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org">
18 <button type="submit">Save</button>
19</form>
20<form method="post" action="{{$base}}" class="setform">
21 <input type="hidden" name="field" value="topics">
22 <label for="topics-add">Topics</label>
23 <input type="text" id="topics-add" name="add" placeholder="add, space-separated">
24 <input type="text" name="remove" placeholder="remove">
25 <button type="submit">Apply</button>
26</form>
27{{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
28
29<h2>Access</h2>
30<form method="post" action="{{$base}}" class="setform">
31 <input type="hidden" name="field" value="visibility">
32 <label for="visibility">Visibility</label>
33 <select id="visibility" name="visibility">
34 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option>
35 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option>
36 </select>
37 <button type="submit">Save</button>
38</form>
39<form method="post" action="{{$base}}" class="setform">
40 <input type="hidden" name="field" value="git-daemon">
41 <label for="git-daemon">Serve over git://</label>
42 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}>
43 <button type="submit">Save</button>
44</form>
45
46<h2>Merge gates</h2>
47<p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p>
48<form method="post" action="{{$base}}" class="setform">
49 <input type="hidden" name="field" value="require-checks">
50 <label for="require-checks">Require green checks</label>
51 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}>
52 <button type="submit">Save</button>
53</form>
54<form method="post" action="{{$base}}" class="setform">
55 <input type="hidden" name="field" value="require-approvals">
56 <label for="approvals">Required approvals</label>
57 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}">
58 <button type="submit">Save</button>
59</form>
60<form method="post" action="{{$base}}" class="setform">
61 <input type="hidden" name="field" value="require-resolved">
62 <label for="require-resolved">Require resolved threads</label>
63 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}>
64 <button type="submit">Save</button>
65</form>
66<form method="post" action="{{$base}}" class="setform">
67 <input type="hidden" name="field" value="require-signed">
68 <label for="require-signed">Require signed commits</label>
69 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}>
70 <button type="submit">Save</button>
71</form>
72
73<h2>Protected branches</h2>
74{{if .Repo.Settings.ProtectedBranches}}
75<ul class="protlist">
76{{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code>
77 <form method="post" action="{{$base}}" class="inline">
78 <input type="hidden" name="field" value="unprotect">
79 <input type="hidden" name="branch" value="{{.}}">
80 <button type="submit" class="linklike">Unprotect</button>
81 </form></li>
82{{end}}
83</ul>
84{{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}}
85<form method="post" action="{{$base}}" class="setform">
86 <input type="hidden" name="field" value="protect">
87 <label for="branch">Protect a branch</label>
88 <select id="branch" name="branch">
89 {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
90 </select>
91 <button type="submit">Protect</button>
92</form>
93
94<h2>Lifecycle</h2>
95<form method="post" action="{{$base}}" class="setform">
96 <input type="hidden" name="field" value="archive">
97 <label for="archive">Archived (read-only)</label>
98 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}>
99 <button type="submit">Save</button>
100</form>
101<p class="meta">Deleting or transferring a repository is a CLI operation:
102<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
103{{end}}