krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
85a32ad0bd44ca25636bcf8013ccb7661e9b7b67
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T03:09:24Z
cmd/gitbay/main.go | 1 + e2e/description_test.go | 76 ++++++++++++++++++++++++++++++++++++++ internal/control/mr.go | 3 ++ internal/control/repo.go | 57 +++++++++++++++++++++++----- internal/gitutil/gitutil.go | 27 ++++++++++++++ internal/httpd/web.go | 26 ++++++++++--- internal/web/static/style.css | 1 + internal/web/templates/index.html | 4 +- internal/web/templates/layout.html | 1 + internal/web/templates/owner.html | 1 + 10 files changed, 180 insertions(+), 17 deletions(-) @@ -220,6 +220,7 @@ func repoCmd() *cobra.Command { pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), + pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}), ), ) new file mode 100644 @@ -0,0 +1,76 @@ +package e2e + +import ( + "strings" + "testing" +) + +func TestRepoDescriptions(t *testing.T) { + inst := startInstance(t) + aliceKey := inst.newKey(t, "alice") + inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") + + // Set at creation; visible in show and list (human and JSON). + if _, errOut, code := inst.ssh(t, aliceKey, "", + "repo", "create", "alice/tool", "--description", "'a fine tool'"); code != 0 { + t.Fatalf("create: %s", errOut) + } + out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json") + if !strings.Contains(out, `"description":"a fine tool"`) { + t.Fatalf("show json: %s", out) + } + out, _, _ = inst.ssh(t, aliceKey, "", "repo", "list") + if !strings.Contains(out, "a fine tool") { + t.Fatalf("list: %s", out) + } + + // The description lives in the bare repo's native description file. + sshOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool") + if !strings.Contains(sshOut, "a fine tool") { + t.Fatalf("show human: %s", sshOut) + } + + // Update via settings; first line only, trimmed. + if _, errOut, code := inst.ssh(t, aliceKey, "", + "repo", "settings", "description", "alice/tool", "'better now'"); code != 0 { + t.Fatalf("settings description: %s", errOut) + } + out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json") + if !strings.Contains(out, `"description":"better now"`) { + t.Fatalf("updated show: %s", out) + } + + // Non-admins cannot set it. + bobKey := inst.newKey(t, "bob") + inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") + if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "description", "alice/tool", "hax"); code != 4 { + t.Fatalf("non-admin set: exit %d, want 4", code) + } + + // Web: index, owner page, and repo header all show it. + for _, path := range []string{"/", "/alice", "/alice/tool"} { + status, body := inst.get(t, path) + if status != 200 || !strings.Contains(body, "better now") { + t.Fatalf("description missing at %s (%d)", path, status) + } + } + + // Forks inherit the description. + if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/tool"); code != 0 { + t.Fatalf("fork: %s", errOut) + } + out, _, _ = inst.ssh(t, bobKey, "", "repo", "show", "bob/tool", "--json") + if !strings.Contains(out, `"description":"better now"`) { + t.Fatalf("fork description: %s", out) + } + + // A repo with no description set stays clean (git's placeholder is + // treated as empty). + if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/plain"); code != 0 { + t.Fatal("plain create failed") + } + out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/plain", "--json") + if strings.Contains(out, "Unnamed repository") || strings.Contains(out, `"description"`) { + t.Fatalf("placeholder leaked: %s", out) + } +} @@ -81,6 +81,9 @@ func runRepoFork(c *Ctx, args []string) int { c.Store.DeleteRepo(id) return c.fail(protocol.ExitFailure, "%v", err) } + if desc := gitutil.ReadDescription(srcDir); desc != "" { + gitutil.WriteDescription(dstDir, desc) + } if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil { // Empty source repos have nothing to fetch; that is fine. if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil { @@ -46,6 +46,8 @@ func init() { Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) register(Command{Path: []string{"repo", "settings", "unprotect"}, Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect}) + register(Command{Path: []string{"repo", "settings", "description"}, + Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) register(Command{Path: []string{"repo", "settings", "git-daemon"}, Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon}) } @@ -76,16 +78,22 @@ func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) func runRepoCreate(c *Ctx, args []string) int { visibility := "public" - var path string - for _, a := range args { - switch a { + var path, description string + for i := 0; i < len(args); i++ { + switch args[i] { case "--private": visibility = "private" + case "--description": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--description requires a value") + } + description = args[i+1] + i++ default: if path != "" { - return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") + return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]") } - path = a + path = args[i] } } owner, name, ok := strings.Cut(path, "/") @@ -119,6 +127,11 @@ func runRepoCreate(c *Ctx, args []string) int { c.Store.DeleteRepo(id) return c.fail(protocol.ExitFailure, "initializing repository: %v", err) } + if description != "" { + if err := gitutil.WriteDescription(dir, description); err != nil { + return c.fail(protocol.ExitFailure, "writing description: %v", err) + } + } type out struct { Path string `json:"path"` Visibility string `json:"visibility"` @@ -143,16 +156,18 @@ func runRepoList(c *Ctx, args []string) int { return c.fail(protocol.ExitFailure, "%v", err) } type out struct { - Path string `json:"path"` - Visibility string `json:"visibility"` + Path string `json:"path"` + Visibility string `json:"visibility"` + Description string `json:"description,omitempty"` } var ds []out for _, r := range repos { - ds = append(ds, out{r.Path(), r.Visibility}) + desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) + ds = append(ds, out{r.Path(), r.Visibility, desc}) } return c.emit(ds, func(w io.Writer) { for _, d := range ds { - fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility) + fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description) } }) } @@ -167,13 +182,18 @@ func runRepoShow(c *Ctx, args []string) int { } type out struct { Path string `json:"path"` + Description string `json:"description,omitempty"` Visibility string `json:"visibility"` DefaultBranch string `json:"default_branch"` ProtectedBranches []string `json:"protected_branches,omitempty"` } - d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches} + desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) + d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches} return c.emit(d, func(w io.Writer) { fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch) + if d.Description != "" { + fmt.Fprintf(w, "%s\n", d.Description) + } if len(d.ProtectedBranches) > 0 { fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) } @@ -353,6 +373,23 @@ func runSettingsShow(c *Ctx, args []string) int { }) } +func runSetDescription(c *Ctx, args []string) int { + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>") + } + repo, code := resolveRepo(c, args[0], policy.CanAdmin) + if code >= 0 { + return code + } + dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) + if err := gitutil.WriteDescription(dir, args[1]); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) { + fmt.Fprintf(w, "description set on %s\n", repo.Path()) + }) +} + func runGitDaemon(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "on" && args[1] != "off") { return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off") @@ -146,3 +146,30 @@ func SetHead(dir, branch string) error { } return nil } + +// gitDefaultDescription is the placeholder git init writes; treated as no +// description at all. +const gitDefaultDescription = "Unnamed repository; edit this file 'description' to name the repository." + +// ReadDescription returns the repo's description from the classic +// <repo>.git/description file, empty for the git-init placeholder. +func ReadDescription(dir string) string { + raw, err := os.ReadFile(filepath.Join(dir, "description")) + if err != nil { + return "" + } + desc := strings.TrimSpace(string(raw)) + if desc == gitDefaultDescription { + return "" + } + return desc +} + +// WriteDescription sets the description file: first line only, capped. +func WriteDescription(dir, desc string) error { + desc, _, _ = strings.Cut(strings.TrimSpace(desc), "\n") + if len(desc) > 256 { + desc = desc[:256] + } + return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644) +} @@ -48,6 +48,20 @@ func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { w.Write(web.StyleCSS) } +// describedRepo pairs a repo with its description for listings. +type describedRepo struct { + store.Repo + Desc string +} + +func (s *Server) describeAll(repos []store.Repo) []describedRepo { + var out []describedRepo + for _, r := range repos { + out = append(out, describedRepo{r, gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name))}) + } + return out +} + func (s *Server) index(w http.ResponseWriter, r *http.Request) { repos, err := s.st.ListPublicRepos() if err != nil { @@ -71,15 +85,16 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { s.render(w, "index.html", struct { Site string Viewer string - Repos []store.Repo - Mine []store.Repo - }{s.siteName(), viewer.Username, repos, mine}) + Repos []describedRepo + Mine []describedRepo + }{s.siteName(), viewer.Username, s.describeAll(repos), s.describeAll(mine)}) } // repoPage is the shared context for repo-scoped pages. type repoPage struct { Site string Viewer string + Desc string Repo store.Repo Ref string CloneURL string @@ -115,6 +130,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re return repoPage{ Site: s.siteName(), Viewer: viewer.Username, + Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), Repo: repo, Ref: ref, CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", @@ -186,10 +202,10 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { Viewer string Owner string Kind string - Repos []store.Repo + Repos []describedRepo Members []store.OrgMember Orgs []store.OrgMember - }{s.siteName(), viewer.Username, name, kind, visible, members, orgs}) + }{s.siteName(), viewer.Username, name, kind, s.describeAll(visible), members, orgs}) } func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { @@ -45,6 +45,7 @@ pre.diff .del { color: var(--bad); } pre.diff .hunk { color: var(--link); } pre.diff .meta { color: var(--muted); } .error { color: var(--bad); } +.desc, td.desc { color: var(--muted); } .badge { display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; font-size: 12px; border: 1px solid; @@ -4,12 +4,12 @@ <form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}} <h1>repositories</h1> <table> -{{range .Repos}}<tr><td><a href="/{{.OwnerName}}">{{.OwnerName}}</a>/<a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> +{{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> {{else}}<tr><td>no public repositories</td></tr>{{end}} </table> {{if .Mine}}<h2>your private repositories</h2> <table> -{{range .Mine}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.Visibility}}</td></tr> +{{range .Mine}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td class="desc">{{.Desc}}</td><td>{{.Visibility}}</td></tr> {{end}} </table>{{end}} {{end}} @@ -18,6 +18,7 @@ {{define "repoheader"}} <h1><a href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a>/<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a></h1> +{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} <nav class="tabs"> <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> @@ -6,6 +6,7 @@ <table> {{range .Repos}}<tr> <td><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td> + <td class="desc">{{.Desc}}</td> <td>{{.Visibility}}</td> <td>{{.DefaultBranch}}</td> </tr>