krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

!1 repo descriptions (native git description file) merged

by cmc · krz/gitbay:repo-descriptions → main @ 85a32ad0bd44ca25636bcf8013ccb7661e9b7b67

Implements #4 using the native git description file rather than a database column: no migration, and descriptions ride along in backups, transfers, and org renames for free. The git-init placeholder is treated as empty.

review: cmc — approve stale

diff

 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(-)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index 19e9da3..a1a7b15 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -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}),
 		),
 	)
diff --git a/e2e/description_test.go b/e2e/description_test.go
new file mode 100644
index 0000000..dcf21ae
--- /dev/null
+++ b/e2e/description_test.go
@@ -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)
+	}
+}
diff --git a/internal/control/mr.go b/internal/control/mr.go
index 30bf402..553ec23 100644
--- a/internal/control/mr.go
+++ b/internal/control/mr.go
@@ -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 {
diff --git a/internal/control/repo.go b/internal/control/repo.go
index 8fb1005..2689582 100644
--- a/internal/control/repo.go
+++ b/internal/control/repo.go
@@ -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")
diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go
index 2bb7461..4ef82a9 100644
--- a/internal/gitutil/gitutil.go
+++ b/internal/gitutil/gitutil.go
@@ -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)
+}
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index 1c8f395..7f76977 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -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) {
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index 4285d28..6ee2837 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -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;
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index 30ace6c..dc186da 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -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}}
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html
index 5f55c43..edc0a2b 100644
--- a/internal/web/templates/layout.html
+++ b/internal/web/templates/layout.html
@@ -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>
diff --git a/internal/web/templates/owner.html b/internal/web/templates/owner.html
index 5a0519c..708db69 100644
--- a/internal/web/templates/owner.html
+++ b/internal/web/templates/owner.html
@@ -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>