Commit 85a32ad0bd

85a32ad0bd44ca25636bcf8013ccb7661e9b7b67

parent: a3aaeec269

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 03:09 UTC

repo descriptions via the native git description file

The classic <repo>.git/description file is the store — no schema
change, and descriptions ride along in backups, transfers, and org
renames for free. git-init's placeholder reads as empty.

- repo create --description, repo settings description (repo admin;
  first line only, 256-char cap), shown in repo show and repo list
  (human and --json)
- web: muted description line under the repo header, columns on the
  index and owner pages
- forks inherit the source description
- e2e: create/update/permission-denial, placeholder suppression, fork
  inheritance, presence on all three web surfaces

Layout: unified · split

cmd/gitbay/main.go +1
@@ -220,6 +220,7 @@ func repoCmd() *cobra.Command {
220 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), 220 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
221 pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), 221 pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
222 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), 222 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
223 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
223 pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}), 224 pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
224 ), 225 ),
225 ) 226 )
e2e/description_test.go added +76
@@ -0,0 +1,76 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestRepoDescriptions(t *testing.T) {
9 inst := startInstance(t)
10 aliceKey := inst.newKey(t, "alice")
11 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
12
13 // Set at creation; visible in show and list (human and JSON).
14 if _, errOut, code := inst.ssh(t, aliceKey, "",
15 "repo", "create", "alice/tool", "--description", "'a fine tool'"); code != 0 {
16 t.Fatalf("create: %s", errOut)
17 }
18 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json")
19 if !strings.Contains(out, `"description":"a fine tool"`) {
20 t.Fatalf("show json: %s", out)
21 }
22 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "list")
23 if !strings.Contains(out, "a fine tool") {
24 t.Fatalf("list: %s", out)
25 }
26
27 // The description lives in the bare repo's native description file.
28 sshOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool")
29 if !strings.Contains(sshOut, "a fine tool") {
30 t.Fatalf("show human: %s", sshOut)
31 }
32
33 // Update via settings; first line only, trimmed.
34 if _, errOut, code := inst.ssh(t, aliceKey, "",
35 "repo", "settings", "description", "alice/tool", "'better now'"); code != 0 {
36 t.Fatalf("settings description: %s", errOut)
37 }
38 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json")
39 if !strings.Contains(out, `"description":"better now"`) {
40 t.Fatalf("updated show: %s", out)
41 }
42
43 // Non-admins cannot set it.
44 bobKey := inst.newKey(t, "bob")
45 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
46 if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "description", "alice/tool", "hax"); code != 4 {
47 t.Fatalf("non-admin set: exit %d, want 4", code)
48 }
49
50 // Web: index, owner page, and repo header all show it.
51 for _, path := range []string{"/", "/alice", "/alice/tool"} {
52 status, body := inst.get(t, path)
53 if status != 200 || !strings.Contains(body, "better now") {
54 t.Fatalf("description missing at %s (%d)", path, status)
55 }
56 }
57
58 // Forks inherit the description.
59 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/tool"); code != 0 {
60 t.Fatalf("fork: %s", errOut)
61 }
62 out, _, _ = inst.ssh(t, bobKey, "", "repo", "show", "bob/tool", "--json")
63 if !strings.Contains(out, `"description":"better now"`) {
64 t.Fatalf("fork description: %s", out)
65 }
66
67 // A repo with no description set stays clean (git's placeholder is
68 // treated as empty).
69 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/plain"); code != 0 {
70 t.Fatal("plain create failed")
71 }
72 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/plain", "--json")
73 if strings.Contains(out, "Unnamed repository") || strings.Contains(out, `"description"`) {
74 t.Fatalf("placeholder leaked: %s", out)
75 }
76}
internal/control/mr.go +3
@@ -81,6 +81,9 @@ func runRepoFork(c *Ctx, args []string) int {
81 c.Store.DeleteRepo(id) 81 c.Store.DeleteRepo(id)
82 return c.fail(protocol.ExitFailure, "%v", err) 82 return c.fail(protocol.ExitFailure, "%v", err)
83 } 83 }
84 if desc := gitutil.ReadDescription(srcDir); desc != "" {
85 gitutil.WriteDescription(dstDir, desc)
86 }
84 if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil { 87 if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
85 // Empty source repos have nothing to fetch; that is fine. 88 // Empty source repos have nothing to fetch; that is fine.
86 if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil { 89 if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
internal/control/repo.go +47 −10
@@ -46,6 +46,8 @@ func init() {
46 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) 46 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
47 register(Command{Path: []string{"repo", "settings", "unprotect"}, 47 register(Command{Path: []string{"repo", "settings", "unprotect"},
48 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect}) 48 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
49 register(Command{Path: []string{"repo", "settings", "description"},
50 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
49 register(Command{Path: []string{"repo", "settings", "git-daemon"}, 51 register(Command{Path: []string{"repo", "settings", "git-daemon"},
50 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon}) 52 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
51} 53}
@@ -76,16 +78,22 @@ func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string)
76 78
77func runRepoCreate(c *Ctx, args []string) int { 79func runRepoCreate(c *Ctx, args []string) int {
78 visibility := "public" 80 visibility := "public"
79 var path string 81 var path, description string
80 for _, a := range args { 82 for i := 0; i < len(args); i++ {
81 switch a { 83 switch args[i] {
82 case "--private": 84 case "--private":
83 visibility = "private" 85 visibility = "private"
86 case "--description":
87 if i+1 >= len(args) {
88 return c.fail(protocol.ExitUsage, "--description requires a value")
89 }
90 description = args[i+1]
91 i++
84 default: 92 default:
85 if path != "" { 93 if path != "" {
86 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") 94 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]")
87 } 95 }
88 path = a 96 path = args[i]
89 } 97 }
90 } 98 }
91 owner, name, ok := strings.Cut(path, "/") 99 owner, name, ok := strings.Cut(path, "/")
@@ -119,6 +127,11 @@ func runRepoCreate(c *Ctx, args []string) int {
119 c.Store.DeleteRepo(id) 127 c.Store.DeleteRepo(id)
120 return c.fail(protocol.ExitFailure, "initializing repository: %v", err) 128 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
121 } 129 }
130 if description != "" {
131 if err := gitutil.WriteDescription(dir, description); err != nil {
132 return c.fail(protocol.ExitFailure, "writing description: %v", err)
133 }
134 }
122 type out struct { 135 type out struct {
123 Path string `json:"path"` 136 Path string `json:"path"`
124 Visibility string `json:"visibility"` 137 Visibility string `json:"visibility"`
@@ -143,16 +156,18 @@ func runRepoList(c *Ctx, args []string) int {
143 return c.fail(protocol.ExitFailure, "%v", err) 156 return c.fail(protocol.ExitFailure, "%v", err)
144 } 157 }
145 type out struct { 158 type out struct {
146 Path string `json:"path"` 159 Path string `json:"path"`
147 Visibility string `json:"visibility"` 160 Visibility string `json:"visibility"`
161 Description string `json:"description,omitempty"`
148 } 162 }
149 var ds []out 163 var ds []out
150 for _, r := range repos { 164 for _, r := range repos {
151 ds = append(ds, out{r.Path(), r.Visibility}) 165 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
166 ds = append(ds, out{r.Path(), r.Visibility, desc})
152 } 167 }
153 return c.emit(ds, func(w io.Writer) { 168 return c.emit(ds, func(w io.Writer) {
154 for _, d := range ds { 169 for _, d := range ds {
155 fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility) 170 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
156 } 171 }
157 }) 172 })
158} 173}
@@ -167,13 +182,18 @@ func runRepoShow(c *Ctx, args []string) int {
167 } 182 }
168 type out struct { 183 type out struct {
169 Path string `json:"path"` 184 Path string `json:"path"`
185 Description string `json:"description,omitempty"`
170 Visibility string `json:"visibility"` 186 Visibility string `json:"visibility"`
171 DefaultBranch string `json:"default_branch"` 187 DefaultBranch string `json:"default_branch"`
172 ProtectedBranches []string `json:"protected_branches,omitempty"` 188 ProtectedBranches []string `json:"protected_branches,omitempty"`
173 } 189 }
174 d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches} 190 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
191 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
175 return c.emit(d, func(w io.Writer) { 192 return c.emit(d, func(w io.Writer) {
176 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch) 193 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
194 if d.Description != "" {
195 fmt.Fprintf(w, "%s\n", d.Description)
196 }
177 if len(d.ProtectedBranches) > 0 { 197 if len(d.ProtectedBranches) > 0 {
178 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) 198 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
179 } 199 }
@@ -353,6 +373,23 @@ func runSettingsShow(c *Ctx, args []string) int {
353 }) 373 })
354} 374}
355 375
376func runSetDescription(c *Ctx, args []string) int {
377 if len(args) != 2 {
378 return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
379 }
380 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
381 if code >= 0 {
382 return code
383 }
384 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
385 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
386 return c.fail(protocol.ExitFailure, "%v", err)
387 }
388 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
389 fmt.Fprintf(w, "description set on %s\n", repo.Path())
390 })
391}
392
356func runGitDaemon(c *Ctx, args []string) int { 393func runGitDaemon(c *Ctx, args []string) int {
357 if len(args) != 2 || (args[1] != "on" && args[1] != "off") { 394 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
358 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off") 395 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
internal/gitutil/gitutil.go +27
@@ -146,3 +146,30 @@ func SetHead(dir, branch string) error {
146 } 146 }
147 return nil 147 return nil
148} 148}
149
150// gitDefaultDescription is the placeholder git init writes; treated as no
151// description at all.
152const gitDefaultDescription = "Unnamed repository; edit this file 'description' to name the repository."
153
154// ReadDescription returns the repo's description from the classic
155// <repo>.git/description file, empty for the git-init placeholder.
156func ReadDescription(dir string) string {
157 raw, err := os.ReadFile(filepath.Join(dir, "description"))
158 if err != nil {
159 return ""
160 }
161 desc := strings.TrimSpace(string(raw))
162 if desc == gitDefaultDescription {
163 return ""
164 }
165 return desc
166}
167
168// WriteDescription sets the description file: first line only, capped.
169func WriteDescription(dir, desc string) error {
170 desc, _, _ = strings.Cut(strings.TrimSpace(desc), "\n")
171 if len(desc) > 256 {
172 desc = desc[:256]
173 }
174 return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644)
175}
internal/httpd/web.go +21 −5
@@ -48,6 +48,20 @@ func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
48 w.Write(web.StyleCSS) 48 w.Write(web.StyleCSS)
49} 49}
50 50
51// describedRepo pairs a repo with its description for listings.
52type describedRepo struct {
53 store.Repo
54 Desc string
55}
56
57func (s *Server) describeAll(repos []store.Repo) []describedRepo {
58 var out []describedRepo
59 for _, r := range repos {
60 out = append(out, describedRepo{r, gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name))})
61 }
62 return out
63}
64
51func (s *Server) index(w http.ResponseWriter, r *http.Request) { 65func (s *Server) index(w http.ResponseWriter, r *http.Request) {
52 repos, err := s.st.ListPublicRepos() 66 repos, err := s.st.ListPublicRepos()
53 if err != nil { 67 if err != nil {
@@ -71,15 +85,16 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
71 s.render(w, "index.html", struct { 85 s.render(w, "index.html", struct {
72 Site string 86 Site string
73 Viewer string 87 Viewer string
74 Repos []store.Repo 88 Repos []describedRepo
75 Mine []store.Repo 89 Mine []describedRepo
76 }{s.siteName(), viewer.Username, repos, mine}) 90 }{s.siteName(), viewer.Username, s.describeAll(repos), s.describeAll(mine)})
77} 91}
78 92
79// repoPage is the shared context for repo-scoped pages. 93// repoPage is the shared context for repo-scoped pages.
80type repoPage struct { 94type repoPage struct {
81 Site string 95 Site string
82 Viewer string 96 Viewer string
97 Desc string
83 Repo store.Repo 98 Repo store.Repo
84 Ref string 99 Ref string
85 CloneURL string 100 CloneURL string
@@ -115,6 +130,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
115 return repoPage{ 130 return repoPage{
116 Site: s.siteName(), 131 Site: s.siteName(),
117 Viewer: viewer.Username, 132 Viewer: viewer.Username,
133 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
118 Repo: repo, 134 Repo: repo,
119 Ref: ref, 135 Ref: ref,
120 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", 136 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
@@ -186,10 +202,10 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
186 Viewer string 202 Viewer string
187 Owner string 203 Owner string
188 Kind string 204 Kind string
189 Repos []store.Repo 205 Repos []describedRepo
190 Members []store.OrgMember 206 Members []store.OrgMember
191 Orgs []store.OrgMember 207 Orgs []store.OrgMember
192 }{s.siteName(), viewer.Username, name, kind, visible, members, orgs}) 208 }{s.siteName(), viewer.Username, name, kind, s.describeAll(visible), members, orgs})
193} 209}
194 210
195func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 211func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +1
@@ -45,6 +45,7 @@ pre.diff .del { color: var(--bad); }
45pre.diff .hunk { color: var(--link); } 45pre.diff .hunk { color: var(--link); }
46pre.diff .meta { color: var(--muted); } 46pre.diff .meta { color: var(--muted); }
47.error { color: var(--bad); } 47.error { color: var(--bad); }
48.desc, td.desc { color: var(--muted); }
48.badge { 49.badge {
49 display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; 50 display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px;
50 font-size: 12px; border: 1px solid; 51 font-size: 12px; border: 1px solid;
internal/web/templates/index.html +2 −2
@@ -4,12 +4,12 @@
4<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}} 4<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}}
5<h1>repositories</h1> 5<h1>repositories</h1>
6<table> 6<table>
7{{range .Repos}}<tr><td><a href="/{{.OwnerName}}">{{.OwnerName}}</a>/<a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> 7{{range .Repos}}<tr><td><a href="/{{.OwnerName}}">{{.OwnerName}}</a>/<a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td><td class="desc">{{.Desc}}</td><td>{{.DefaultBranch}}</td></tr>
8{{else}}<tr><td>no public repositories</td></tr>{{end}} 8{{else}}<tr><td>no public repositories</td></tr>{{end}}
9</table> 9</table>
10{{if .Mine}}<h2>your private repositories</h2> 10{{if .Mine}}<h2>your private repositories</h2>
11<table> 11<table>
12{{range .Mine}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.Visibility}}</td></tr> 12{{range .Mine}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td class="desc">{{.Desc}}</td><td>{{.Visibility}}</td></tr>
13{{end}} 13{{end}}
14</table>{{end}} 14</table>{{end}}
15{{end}} 15{{end}}
internal/web/templates/layout.html +1
@@ -18,6 +18,7 @@
18 18
19{{define "repoheader"}} 19{{define "repoheader"}}
20<h1><a href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a>/<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a></h1> 20<h1><a href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a>/<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a></h1>
21{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
21<nav class="tabs"> 22<nav class="tabs">
22 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> 23 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
23 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> 24 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
internal/web/templates/owner.html +1
@@ -6,6 +6,7 @@
6<table> 6<table>
7{{range .Repos}}<tr> 7{{range .Repos}}<tr>
8 <td><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td> 8 <td><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td>
9 <td class="desc">{{.Desc}}</td>
9 <td>{{.Visibility}}</td> 10 <td>{{.Visibility}}</td>
10 <td>{{.DefaultBranch}}</td> 11 <td>{{.DefaultBranch}}</td>
11</tr> 12</tr>