repo descriptions (native git description file) !1

merged merged by cmc on 2026-08-24 03:11 UTC · krz/gitbay:repo-descriptions into main

10 files changed, +180 −17

Layout: unified · split

cmd/gitbay/main.go +1
@@ -220,6 +220,7 @@ func repoCmd() *cobra.Command {
220220 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
221221 pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
222222 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}),
223224 pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
224225 ),
225226 )
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 {
8181 c.Store.DeleteRepo(id)
8282 return c.fail(protocol.ExitFailure, "%v", err)
8383 }
84 if desc := gitutil.ReadDescription(srcDir); desc != "" {
85 gitutil.WriteDescription(dstDir, desc)
86 }
8487 if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
8588 // Empty source repos have nothing to fetch; that is fine.
8689 if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
internal/control/repo.go +47 −10
@@ -46,6 +46,8 @@ func init() {
4646 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
4747 register(Command{Path: []string{"repo", "settings", "unprotect"},
4848 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})
4951 register(Command{Path: []string{"repo", "settings", "git-daemon"},
5052 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
5153}
@@ -76,16 +78,22 @@ func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string)
7678
7779func runRepoCreate(c *Ctx, args []string) int {
7880 visibility := "public"
79 var path string
80 for _, a := range args {
81 switch a {
81 var path, description string
82 for i := 0; i < len(args); i++ {
83 switch args[i] {
8284 case "--private":
8385 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++
8492 default:
8593 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>]")
8795 }
88 path = a
96 path = args[i]
8997 }
9098 }
9199 owner, name, ok := strings.Cut(path, "/")
@@ -119,6 +127,11 @@ func runRepoCreate(c *Ctx, args []string) int {
119127 c.Store.DeleteRepo(id)
120128 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
121129 }
130 if description != "" {
131 if err := gitutil.WriteDescription(dir, description); err != nil {
132 return c.fail(protocol.ExitFailure, "writing description: %v", err)
133 }
134 }
122135 type out struct {
123136 Path string `json:"path"`
124137 Visibility string `json:"visibility"`
@@ -143,16 +156,18 @@ func runRepoList(c *Ctx, args []string) int {
143156 return c.fail(protocol.ExitFailure, "%v", err)
144157 }
145158 type out struct {
146 Path string `json:"path"`
147 Visibility string `json:"visibility"`
159 Path string `json:"path"`
160 Visibility string `json:"visibility"`
161 Description string `json:"description,omitempty"`
148162 }
149163 var ds []out
150164 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})
152167 }
153168 return c.emit(ds, func(w io.Writer) {
154169 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)
156171 }
157172 })
158173}
@@ -167,13 +182,18 @@ func runRepoShow(c *Ctx, args []string) int {
167182 }
168183 type out struct {
169184 Path string `json:"path"`
185 Description string `json:"description,omitempty"`
170186 Visibility string `json:"visibility"`
171187 DefaultBranch string `json:"default_branch"`
172188 ProtectedBranches []string `json:"protected_branches,omitempty"`
173189 }
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}
175192 return c.emit(d, func(w io.Writer) {
176193 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 }
177197 if len(d.ProtectedBranches) > 0 {
178198 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
179199 }
@@ -353,6 +373,23 @@ func runSettingsShow(c *Ctx, args []string) int {
353373 })
354374}
355375
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
356393func runGitDaemon(c *Ctx, args []string) int {
357394 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
358395 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 {
146146 }
147147 return nil
148148}
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) {
4848 w.Write(web.StyleCSS)
4949}
5050
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
5165func (s *Server) index(w http.ResponseWriter, r *http.Request) {
5266 repos, err := s.st.ListPublicRepos()
5367 if err != nil {
@@ -71,15 +85,16 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
7185 s.render(w, "index.html", struct {
7286 Site string
7387 Viewer string
74 Repos []store.Repo
75 Mine []store.Repo
76 }{s.siteName(), viewer.Username, repos, mine})
88 Repos []describedRepo
89 Mine []describedRepo
90 }{s.siteName(), viewer.Username, s.describeAll(repos), s.describeAll(mine)})
7791}
7892
7993// repoPage is the shared context for repo-scoped pages.
8094type repoPage struct {
8195 Site string
8296 Viewer string
97 Desc string
8398 Repo store.Repo
8499 Ref string
85100 CloneURL string
@@ -115,6 +130,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
115130 return repoPage{
116131 Site: s.siteName(),
117132 Viewer: viewer.Username,
133 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
118134 Repo: repo,
119135 Ref: ref,
120136 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
@@ -186,10 +202,10 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
186202 Viewer string
187203 Owner string
188204 Kind string
189 Repos []store.Repo
205 Repos []describedRepo
190206 Members []store.OrgMember
191207 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})
193209}
194210
195211func (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); }
4545pre.diff .hunk { color: var(--link); }
4646pre.diff .meta { color: var(--muted); }
4747.error { color: var(--bad); }
48.desc, td.desc { color: var(--muted); }
4849.badge {
4950 display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px;
5051 font-size: 12px; border: 1px solid;
internal/web/templates/index.html +2 −2
@@ -4,12 +4,12 @@
44<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}}
55<h1>repositories</h1>
66<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>
88{{else}}<tr><td>no public repositories</td></tr>{{end}}
99</table>
1010{{if .Mine}}<h2>your private repositories</h2>
1111<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>
1313{{end}}
1414</table>{{end}}
1515{{end}}
internal/web/templates/layout.html +1
@@ -18,6 +18,7 @@
1818
1919{{define "repoheader"}}
2020<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}}
2122<nav class="tabs">
2223 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
2324 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
internal/web/templates/owner.html +1
@@ -6,6 +6,7 @@
66<table>
77{{range .Repos}}<tr>
88 <td><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td>
9 <td class="desc">{{.Desc}}</td>
910 <td>{{.Visibility}}</td>
1011 <td>{{.DefaultBranch}}</td>
1112</tr>