Commit 86c82f63ee

86c82f63eed724936f8cefbdeb1f7a4b5afcc1f1

parent: ea552d4cb3

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:13 UTC

repo readme: print a repository's README, the web page's file order

Ref #268

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -168,6 +168,7 @@ rather than the one the web page shows.
168168|-----------------------------+-----+-----+-----|
169169| browse files | yes | yes | yes |
170170| read a file | yes | yes | yes |
171| render a README | yes | yes | yes |
171172| commit log | yes | yes | yes |
172173| commit log at a ref | yes | yes | yes |
173174| one commit with its patch | yes | yes | yes |
CHANGELOG.org +2
@@ -94,6 +94,8 @@ for the eighteen commands whose CLI path differs from the registry's
9494 a separate one per field (#268).
9595- =mr show= pluralizes multi-row section headings with counts: =commits (7):=,
9696 =checks (2):=, =reviews (3):= (#268).
97- =repo readme <owner/name> [--ref <ref>]= prints a repository's README,
98 picked the same way the web page picks one (#268).
9799
98100* v1.36.0 — 2026-09-23
99101
cmd/gitbay/main.go +1
@@ -546,6 +546,7 @@ func repoCmd() *cobra.Command {
546546 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
547547 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
548548 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
549 pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}),
549550 pass("blame", passOpts{server: []string{"repo", "blame"}, needsRepo: true}),
550551 pass("commit", passOpts{server: []string{"repo", "commit"}, needsRepo: true}),
551552 pass("commit-file", passOpts{server: []string{"repo", "commit-file"}, needsRepo: true, stdinOK: true}),
cmd/gitbay/summaries_gen.go +1
@@ -169,6 +169,7 @@ var summaries = map[string]string{
169169 "repo mirror sync": "schedule an immediate sync",
170170 "repo mute": "mute a repository, including work you are part of",
171171 "repo pin": "pin a repository to your dashboard",
172 "repo readme": "print a repository's README",
172173 "repo refs": "list branches and tags",
173174 "repo rename": "rename a repository",
174175 "repo runner add": "attach a runner's public key to a repository",
e2e/readonly_test.go +1
@@ -122,6 +122,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
122122 "repo settings show": {"alice/app"},
123123 "repo topics": {"alice/app"},
124124 "repo refs": {"alice/app"},
125 "repo readme": {"alice/app"},
125126 "repo log": {"alice/app"},
126127 "repo tree": {"alice/app"},
127128 "repo cat": {"alice/app", "f.go"},
internal/control/read.go +105
@@ -51,6 +51,17 @@ func init() {
5151 ReadOnly: true,
5252 Run: runRepoBlame,
5353 })
54 register(Command{
55 Path: []string{"repo", "readme"},
56 Summary: "print a repository's README",
57 Usage: "repo readme <owner/name> [--ref <ref>]",
58 Flags: []Flag{
59 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
60 },
61 Examples: []string{"repo readme krz/gitbay"},
62 ReadOnly: true,
63 Run: runRepoReadme,
64 })
5465 register(Command{
5566 Path: []string{"repo", "refs"},
5667 Summary: "list branches and tags",
@@ -312,6 +323,73 @@ func runRepoTree(c *Ctx, args []string) int {
312323 })
313324}
314325
326func runRepoReadme(c *Ctx, args []string) int {
327 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1)
328 if code >= 0 {
329 return code
330 }
331 if len(pos) != 1 {
332 return c.usage()
333 }
334 repo, code := resolveRepo(c, pos[0], policy.CanRead)
335 if code >= 0 {
336 return code
337 }
338 if ref == "" {
339 ref = repo.DefaultBranch
340 }
341 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
342 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
343 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
344 }
345 entries, err := gitutil.ListTree(dir, ref, "")
346 if err != nil {
347 return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref)
348 }
349 name := PickReadme(entries)
350 if name == "" {
351 return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref)
352 }
353 limit := c.Cfg.Limits.MaxBlobBytes
354 data, err := gitutil.ReadBlob(dir, ref, name, limit+1)
355 if err != nil {
356 return c.fail(protocol.ExitFailure, "%v", err)
357 }
358 truncated := int64(len(data)) > limit
359 if truncated {
360 data = data[:limit]
361 }
362 binary := gitutil.IsBinary(data)
363
364 type out struct {
365 Path string `json:"path"`
366 Ref string `json:"ref"`
367 File string `json:"file"`
368 Size int `json:"size"`
369 Binary bool `json:"binary"`
370 Truncated bool `json:"truncated,omitempty"`
371 Content string `json:"content,omitempty"`
372 Base64 string `json:"base64,omitempty"`
373 }
374 d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data),
375 Binary: binary, Truncated: truncated}
376 if binary {
377 d.Base64 = base64.StdEncoding.EncodeToString(data)
378 } else {
379 d.Content = string(data)
380 }
381 return c.emit(d, func(w io.Writer) {
382 if binary {
383 fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", name, len(data))
384 return
385 }
386 w.Write(data)
387 if len(data) > 0 && data[len(data)-1] != '\n' {
388 fmt.Fprintln(w)
389 }
390 })
391}
392
315393func sizeCol(e entryOut) string {
316394 if e.Type == "tree" {
317395 return "-"
@@ -319,6 +397,33 @@ func sizeCol(e entryOut) string {
319397 return fmt.Sprintf("%d", e.Size)
320398}
321399
400// readmeRank orders competing README files: richer renderers win.
401var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
402
403// PickReadme returns the best README-ish blob in a tree listing: any
404// file named "readme" or "readme.<ext>" (case-insensitive), preferring
405// formats we can render richly.
406func PickReadme(entries []gitutil.TreeEntry) string {
407 best, bestRank := "", 1<<30
408 for _, e := range entries {
409 if e.Type != "blob" {
410 continue
411 }
412 lower := strings.ToLower(e.Name)
413 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
414 continue
415 }
416 rank, ok := readmeRank[path.Ext(lower)]
417 if !ok {
418 rank = 10 // plaintext fallback
419 }
420 if rank < bestRank {
421 best, bestRank = e.Name, rank
422 }
423 }
424 return best
425}
426
322427func runRepoCat(c *Ctx, args []string) int {
323428 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
324429 if code >= 0 {
internal/control/read_test.go added +37
@@ -0,0 +1,37 @@
1package control
2
3import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestRepoReadmePicksTheRichestFormat(t *testing.T) {
14 st, repo, uid := newQueueTestRepo(t)
15 git := gitRunner(t)
16 root := t.TempDir()
17
18 src := filepath.Join(root, "src")
19 os.MkdirAll(src, 0o755)
20 os.WriteFile(filepath.Join(src, "README.md"), []byte("# app\n\nhello\n"), 0o644)
21 git(root, "init", "-q", "-b", "main", "src")
22 git(src, "add", ".")
23 git(src, "commit", "-q", "-m", "base")
24
25 dir := RepoDir(root, repo.OwnerName, repo.Name)
26 os.MkdirAll(filepath.Dir(dir), 0o755)
27 git(root, "clone", "-q", "--bare", src, dir)
28
29 c, errOut := pruneCtx(st, root, store.User{ID: uid})
30 c.Cfg.Limits.MaxBlobBytes = 100 << 20
31 if code := Dispatch(c, []string{"repo", "readme", repo.Path()}); code != protocol.ExitOK {
32 t.Fatalf("exit %d: %s", code, errOut)
33 }
34 if got := c.Stdout.(*bytes.Buffer).String(); got != "# app\n\nhello\n" {
35 t.Errorf("readme = %q", got)
36 }
37}
internal/httpd/web.go +1 −28
@@ -657,7 +657,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
657657 }
658658
659659 var readmeHTML template.HTML
660 readmeName := pickReadme(entries)
660 readmeName := control.PickReadme(entries)
661661 if readmeName != "" {
662662 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
663663 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
@@ -1182,33 +1182,6 @@ var imageTypes = map[string]string{
11821182 ".svg": "image/svg+xml", ".ico": "image/x-icon",
11831183}
11841184
1185// readmeRank orders competing README files: richer renderers win.
1186var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
1187
1188// pickReadme returns the best README-ish blob in a tree listing: any file
1189// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
1190// we can render richly.
1191func pickReadme(entries []gitutil.TreeEntry) string {
1192 best, bestRank := "", 1<<30
1193 for _, e := range entries {
1194 if e.Type != "blob" {
1195 continue
1196 }
1197 lower := strings.ToLower(e.Name)
1198 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
1199 continue
1200 }
1201 rank, ok := readmeRank[path.Ext(lower)]
1202 if !ok {
1203 rank = 10 // plaintext fallback
1204 }
1205 if rank < bestRank {
1206 best, bestRank = e.Name, rank
1207 }
1208 }
1209 return best
1210}
1211
12121185// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
12131186// task lists) on top of CommonMark, with class-based fence highlighting
12141187// (the palette lives in the stylesheet, per scheme). Raw HTML is still