repo descriptions (native git description file) !1
10 files changed, +180 −17
Layout: unified · split
cmd/gitbay/main.go +1
| @@ -220,6 +220,7 @@ func repoCmd() *cobra.Command { | ||
| 220 | 220 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 221 | 221 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), |
| 222 | 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 | 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 | 81 | c.Store.DeleteRepo(id) |
| 82 | 82 | return c.fail(protocol.ExitFailure, "%v", err) |
| 83 | 83 | } |
| 84 | if desc := gitutil.ReadDescription(srcDir); desc != "" { | |
| 85 | gitutil.WriteDescription(dstDir, desc) | |
| 86 | } | |
| 84 | 87 | if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil { |
| 85 | 88 | // Empty source repos have nothing to fetch; that is fine. |
| 86 | 89 | if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil { |
internal/control/repo.go +47 −10
| @@ -46,6 +46,8 @@ func init() { | ||
| 46 | 46 | Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) |
| 47 | 47 | register(Command{Path: []string{"repo", "settings", "unprotect"}, |
| 48 | 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 | 51 | register(Command{Path: []string{"repo", "settings", "git-daemon"}, |
| 50 | 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 | |
| 77 | 79 | func runRepoCreate(c *Ctx, args []string) int { |
| 78 | 80 | 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] { | |
| 82 | 84 | case "--private": |
| 83 | 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 | 92 | default: |
| 85 | 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 | 99 | owner, name, ok := strings.Cut(path, "/") |
| @@ -119,6 +127,11 @@ func runRepoCreate(c *Ctx, args []string) int { | ||
| 119 | 127 | c.Store.DeleteRepo(id) |
| 120 | 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 | 135 | type out struct { |
| 123 | 136 | Path string `json:"path"` |
| 124 | 137 | Visibility string `json:"visibility"` |
| @@ -143,16 +156,18 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 143 | 156 | return c.fail(protocol.ExitFailure, "%v", err) |
| 144 | 157 | } |
| 145 | 158 | 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"` | |
| 148 | 162 | } |
| 149 | 163 | var ds []out |
| 150 | 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 | 168 | return c.emit(ds, func(w io.Writer) { |
| 154 | 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 | 183 | type out struct { |
| 169 | 184 | Path string `json:"path"` |
| 185 | Description string `json:"description,omitempty"` | |
| 170 | 186 | Visibility string `json:"visibility"` |
| 171 | 187 | DefaultBranch string `json:"default_branch"` |
| 172 | 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 | 192 | return c.emit(d, func(w io.Writer) { |
| 176 | 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 | 197 | if len(d.ProtectedBranches) > 0 { |
| 178 | 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 | |
| 376 | func 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 | ||
| 356 | 393 | func runGitDaemon(c *Ctx, args []string) int { |
| 357 | 394 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 358 | 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 | 147 | return nil |
| 148 | 148 | } |
| 149 | ||
| 150 | // gitDefaultDescription is the placeholder git init writes; treated as no | |
| 151 | // description at all. | |
| 152 | const 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. | |
| 156 | func 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. | |
| 169 | func 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 | 48 | w.Write(web.StyleCSS) |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | // describedRepo pairs a repo with its description for listings. | |
| 52 | type describedRepo struct { | |
| 53 | store.Repo | |
| 54 | Desc string | |
| 55 | } | |
| 56 | ||
| 57 | func (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 | ||
| 51 | 65 | func (s *Server) index(w http.ResponseWriter, r *http.Request) { |
| 52 | 66 | repos, err := s.st.ListPublicRepos() |
| 53 | 67 | if err != nil { |
| @@ -71,15 +85,16 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 71 | 85 | s.render(w, "index.html", struct { |
| 72 | 86 | Site string |
| 73 | 87 | 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)}) | |
| 77 | 91 | } |
| 78 | 92 | |
| 79 | 93 | // repoPage is the shared context for repo-scoped pages. |
| 80 | 94 | type repoPage struct { |
| 81 | 95 | Site string |
| 82 | 96 | Viewer string |
| 97 | Desc string | |
| 83 | 98 | Repo store.Repo |
| 84 | 99 | Ref string |
| 85 | 100 | CloneURL string |
| @@ -115,6 +130,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | ||
| 115 | 130 | return repoPage{ |
| 116 | 131 | Site: s.siteName(), |
| 117 | 132 | Viewer: viewer.Username, |
| 133 | Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), | |
| 118 | 134 | Repo: repo, |
| 119 | 135 | Ref: ref, |
| 120 | 136 | CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", |
| @@ -186,10 +202,10 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 186 | 202 | Viewer string |
| 187 | 203 | Owner string |
| 188 | 204 | Kind string |
| 189 | Repos []store.Repo | |
| 205 | Repos []describedRepo | |
| 190 | 206 | Members []store.OrgMember |
| 191 | 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 | |
| 195 | 211 | func (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); } | ||
| 45 | 45 | pre.diff .hunk { color: var(--link); } |
| 46 | 46 | pre.diff .meta { color: var(--muted); } |
| 47 | 47 | .error { color: var(--bad); } |
| 48 | .desc, td.desc { color: var(--muted); } | |
| 48 | 49 | .badge { |
| 49 | 50 | display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; |
| 50 | 51 | font-size: 12px; border: 1px solid; |
internal/web/templates/index.html +2 −2
| @@ -4,12 +4,12 @@ | ||
| 4 | 4 | <form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}} |
| 5 | 5 | <h1>repositories</h1> |
| 6 | 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 | 8 | {{else}}<tr><td>no public repositories</td></tr>{{end}} |
| 9 | 9 | </table> |
| 10 | 10 | {{if .Mine}}<h2>your private repositories</h2> |
| 11 | 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 | 13 | {{end}} |
| 14 | 14 | </table>{{end}} |
| 15 | 15 | {{end}} |
internal/web/templates/layout.html +1
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | |
| 19 | 19 | {{define "repoheader"}} |
| 20 | 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 | 22 | <nav class="tabs"> |
| 22 | 23 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> |
| 23 | 24 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> |
internal/web/templates/owner.html +1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | <table> |
| 7 | 7 | {{range .Repos}}<tr> |
| 8 | 8 | <td><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td> |
| 9 | <td class="desc">{{.Desc}}</td> | |
| 9 | 10 | <td>{{.Visibility}}</td> |
| 10 | 11 | <td>{{.DefaultBranch}}</td> |
| 11 | 12 | </tr> |