A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit b754784fc6

b754784fc664a3d8fe81e4c66c327792fa6c9bf9

parent: 794702c58d

Verified · cmc

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

Add a per-repo website setting

repo settings website <owner/name> <url> ('' clears), admin-only,
https/http only, max 256 chars. Shown in repo show and linked under
the description in the web repo header.
e2e/description_test.go +32
@@ -55,6 +55,38 @@ func TestRepoDescriptions(t *testing.T) {
5555 }
5656 }
5757
58 // Website: set, shown in show and on the repo page; bad scheme and
59 // non-admins refused.
60 if _, errOut, code := inst.ssh(t, aliceKey, "",
61 "repo", "settings", "website", "alice/tool", "https://tool.example.org"); code != 0 {
62 t.Fatalf("settings website: %s", errOut)
63 }
64 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json")
65 if !strings.Contains(out, `"website":"https://tool.example.org"`) {
66 t.Fatalf("website show: %s", out)
67 }
68 if _, body := inst.get(t, "/alice/tool"); !strings.Contains(body, `href="https://tool.example.org"`) {
69 t.Fatalf("website missing on repo page:\n%s", body)
70 }
71 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "website", "alice/tool", "javascript:alert(1)"); code != 2 {
72 t.Fatalf("bad scheme accepted")
73 }
74 if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "website", "alice/tool", "https://x.example"); code != 4 {
75 t.Fatalf("non-admin set website: want exit 4")
76 }
77 // Clearing removes it.
78 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "website", "alice/tool", "''"); code != 0 {
79 t.Fatal("website clear failed")
80 }
81 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json")
82 if strings.Contains(out, `"website"`) {
83 t.Fatalf("website not cleared: %s", out)
84 }
85 if _, _, code := inst.ssh(t, aliceKey, "",
86 "repo", "settings", "website", "alice/tool", "https://tool.example.org"); code != 0 {
87 t.Fatal("website re-set failed")
88 }
89
5890 // Forks inherit the description.
5991 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/tool"); code != 0 {
6092 t.Fatalf("fork: %s", errOut)
internal/control/repo.go +37 −2
@@ -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", "website"},
52 Summary: "set the repository website: repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite})
5153 register(Command{Path: []string{"repo", "settings", "git-daemon"},
5254 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
5355 register(Command{Path: []string{"repo", "archive"},
@@ -236,6 +238,7 @@ func runRepoShow(c *Ctx, args []string) int {
236238 type out struct {
237239 Path string `json:"path"`
238240 Description string `json:"description,omitempty"`
241 Website string `json:"website,omitempty"`
239242 Visibility string `json:"visibility"`
240243 DefaultBranch string `json:"default_branch"`
241244 ProtectedBranches []string `json:"protected_branches,omitempty"`
@@ -248,8 +251,8 @@ func runRepoShow(c *Ctx, args []string) int {
248251 if err != nil {
249252 return c.fail(protocol.ExitFailure, "%v", err)
250253 }
251 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches,
252 repo.Settings.Archived, topics, nil}
254 d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch,
255 repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, nil}
253256 // Mirror status is admin-only, like repo mirror list. The token never
254257 // leaves the server.
255258 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
@@ -270,6 +273,9 @@ func runRepoShow(c *Ctx, args []string) int {
270273 if d.Description != "" {
271274 fmt.Fprintf(w, "%s\n", d.Description)
272275 }
276 if d.Website != "" {
277 fmt.Fprintf(w, "website: %s\n", d.Website)
278 }
273279 if len(d.Topics) > 0 {
274280 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
275281 }
@@ -485,6 +491,35 @@ func runSetDescription(c *Ctx, args []string) int {
485491 })
486492 }
487493
494func runSetWebsite(c *Ctx, args []string) int {
495 if len(args) != 2 {
496 return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
497 }
498 site := strings.TrimSpace(args[1])
499 if err := validateWebsite(site); err != nil {
500 return c.fail(protocol.ExitUsage, "%v", err)
501 }
502 if len(site) > 256 {
503 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
504 }
505 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
506 if code >= 0 {
507 return code
508 }
509 s := repo.Settings
510 s.Website = site
511 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
512 return c.fail(protocol.ExitFailure, "%v", err)
513 }
514 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
515 if site == "" {
516 fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
517 } else {
518 fmt.Fprintf(w, "website set on %s\n", repo.Path())
519 }
520 })
521}
522
488523 func runGitDaemon(c *Ctx, args []string) int {
489524 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
490525 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
internal/store/repos.go +1
@@ -28,6 +28,7 @@ type RepoSettings struct {
2828 RequireResolved bool `json:"require_resolved,omitempty"`
2929 GitDaemon bool `json:"git_daemon,omitempty"`
3030 Archived bool `json:"archived,omitempty"`
31 Website string `json:"website,omitempty"`
3132 }
3233
3334 // Path returns the canonical owner/name form.
internal/web/templates/layout.html +1
@@ -31,6 +31,7 @@
3131 <div class="repohead">
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}}
34{{if .Repo.Settings.Website}}<p class="meta repowebsite"><a href="{{.Repo.Settings.Website}}" rel="nofollow">{{.Repo.Settings.Website}}</a></p>{{end}}
3435 {{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
3536 {{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
3637 {{end}}<nav class="tabs">