internal/control/read.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/control/read.go history · blame · raw

541 lines · 16241 bytes

12 symbols in this file
  1package control
  2
  3import (
  4	"bytes"
  5	"encoding/base64"
  6	"fmt"
  7	"io"
  8	"path"
  9	"slices"
 10	"strconv"
 11	"strings"
 12	"time"
 13
 14	"gitbay.org/gitbay/internal/gitutil"
 15	"gitbay.org/gitbay/internal/policy"
 16	"gitbay.org/gitbay/internal/protocol"
 17)
 18
 19func init() {
 20	register(Command{
 21		Path:    []string{"repo", "tree"},
 22		Summary: "list a directory",
 23		Usage:   "repo tree <owner/name> [<path>] [--ref <ref>]",
 24		Flags: []Flag{
 25			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
 26		},
 27		Examples: []string{"repo tree krz/gitbay internal/control"},
 28		ReadOnly: true,
 29		Run:      runRepoTree,
 30	})
 31	register(Command{
 32		Path:    []string{"repo", "cat"},
 33		Summary: "read a file",
 34		Usage:   "repo cat <owner/name> <path> [--ref <ref>]",
 35		Flags: []Flag{
 36			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
 37		},
 38		Examples: []string{"repo cat krz/gitbay internal/control/control.go"},
 39		ReadOnly: true,
 40		Run:      runRepoCat,
 41	})
 42	register(Command{
 43		Path:    []string{"repo", "blame"},
 44		Summary: "attribute lines to commits",
 45		Usage:   "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]",
 46		Flags: []Flag{
 47			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
 48			{"--from", "<n>", "first line to attribute", "1"},
 49			{"--to", "<n>", "last line to attribute", "the file's last line"},
 50		},
 51		Examples: []string{"repo blame krz/gitbay internal/control/control.go --from 1 --to 40"},
 52		ReadOnly: true,
 53		Run:      runRepoBlame,
 54	})
 55	register(Command{
 56		Path:    []string{"repo", "readme"},
 57		Summary: "print a repository's README",
 58		Usage:   "repo readme <owner/name> [--ref <ref>]",
 59		Flags: []Flag{
 60			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
 61		},
 62		Examples: []string{"repo readme krz/gitbay"},
 63		ReadOnly: true,
 64		Run:      runRepoReadme,
 65	})
 66	register(Command{
 67		Path:     []string{"repo", "refs"},
 68		Summary:  "list branches and tags",
 69		Usage:    "repo refs <owner/name>",
 70		Examples: []string{"repo refs krz/gitbay"},
 71		ReadOnly: true,
 72		Run:      runRepoRefs,
 73	})
 74}
 75
 76func runRepoRefs(c *Ctx, args []string) int {
 77	if len(args) != 1 {
 78		return c.usage()
 79	}
 80	repo, code := resolveRepo(c, args[0], policy.CanRead)
 81	if code >= 0 {
 82		return code
 83	}
 84	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 85	branches, err := gitutil.Refs(dir, "heads")
 86	if err != nil {
 87		return c.fail(protocol.ExitFailure, "listing branches: %v", err)
 88	}
 89	tags, err := gitutil.Refs(dir, "tags")
 90	if err != nil {
 91		return c.fail(protocol.ExitFailure, "listing tags: %v", err)
 92	}
 93	type refOut struct {
 94		Name string `json:"name"`
 95		SHA  string `json:"sha"`
 96	}
 97	type out struct {
 98		Branches []refOut `json:"branches"`
 99		Tags     []refOut `json:"tags"`
100	}
101	d := out{Branches: []refOut{}, Tags: []refOut{}}
102	for _, ref := range branches {
103		d.Branches = append(d.Branches, refOut{Name: ref.Name, SHA: ref.SHA})
104	}
105	for _, ref := range tags {
106		d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA})
107	}
108	// Tags read newest version first; the JSON keeps git's order.
109	sorted := slices.Clone(tags)
110	gitutil.SortVersions(sorted)
111	return c.emitView(d, func(w io.Writer) {
112		tb := c.table(w, "KIND", "NAME", "SHA")
113		for _, ref := range d.Branches {
114			tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
115		}
116		for _, ref := range sorted {
117			tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
118		}
119		tb.flush()
120	}, func() screen {
121		branches := section{title: "Branches", n: len(d.Branches)}
122		for _, r := range d.Branches {
123			if r.Name == repo.DefaultBranch {
124				branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA), "default")))
125			}
126		}
127		for _, r := range d.Branches {
128			if r.Name != repo.DefaultBranch {
129				branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
130			}
131		}
132		tags := section{title: "Tags", n: len(sorted)}
133		for _, r := range sorted {
134			tags.rows = append(tags.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
135		}
136		return screen{sections: []section{branches, tags}, actions: []action{
137			{"Read", []string{"repo", "log", repo.Path(), "--ref", repo.DefaultBranch}},
138		}}
139	})
140}
141
142// BlameSpan caps one blame request, and is the page size the web renders.
143// An unbounded blame on a large file is a slow query for every surface.
144const BlameSpan = 1000
145
146func runRepoBlame(c *Ctx, args []string) int {
147	f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage})
148	if err != nil {
149		return c.fail(protocol.ExitUsage, "%v", err)
150	}
151	rest, ref := f.Pos, f.Value("--ref")
152	from, to := 0, 0
153	for name, dst := range map[string]*int{"--from": &from, "--to": &to} {
154		if !f.Has(name) {
155			continue
156		}
157		n, err := strconv.Atoi(f.Value(name))
158		if err != nil || n < 1 {
159			return c.fail(protocol.ExitUsage, "%s must be a positive line number", name)
160		}
161		*dst = n
162	}
163	if len(rest) != 2 {
164		return c.usage()
165	}
166	repo, code := resolveRepo(c, rest[0], policy.CanRead)
167	if code >= 0 {
168		return code
169	}
170	filePath, ok := cleanRepoPath(rest[1])
171	if !ok || filePath == "" {
172		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
173	}
174	if ref == "" {
175		ref = repo.DefaultBranch
176	}
177	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
178	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
179		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
180	}
181	data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes)
182	if err != nil {
183		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
184	}
185	if gitutil.IsBinary(data) {
186		return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath)
187	}
188	total := bytes.Count(data, []byte("\n"))
189	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
190		total++
191	}
192	if total == 0 {
193		return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref)
194	}
195	if from == 0 {
196		from = 1
197	}
198	if from > total {
199		return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total)
200	}
201	if to == 0 || to > total {
202		to = total
203	}
204	if to < from {
205		return c.fail(protocol.ExitUsage, "--to must not precede --from")
206	}
207	// One span per call; a client pages with --from/--to.
208	if to-from+1 > BlameSpan {
209		to = from + BlameSpan - 1
210	}
211	raw, err := gitutil.Blame(dir, ref, filePath, from, to)
212	if err != nil {
213		return c.fail(protocol.ExitFailure, "%v", err)
214	}
215
216	type hunkOut struct {
217		SHA         string   `json:"sha"`
218		AuthorName  string   `json:"author_name"`
219		AuthorEmail string   `json:"author_email"`
220		Date        string   `json:"date"`
221		Summary     string   `json:"summary"`
222		StartLine   int      `json:"start_line"`
223		Lines       []string `json:"lines"`
224	}
225	type out struct {
226		Path       string    `json:"path"`
227		Ref        string    `json:"ref"`
228		File       string    `json:"file"`
229		From       int       `json:"from"`
230		To         int       `json:"to"`
231		TotalLines int       `json:"total_lines"`
232		Hunks      []hunkOut `json:"hunks"`
233	}
234	d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to,
235		TotalLines: total, Hunks: []hunkOut{}}
236	for _, h := range raw {
237		d.Hunks = append(d.Hunks, hunkOut{
238			SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail,
239			Date:      time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339),
240			Summary:   h.Summary,
241			StartLine: h.StartLine, Lines: h.Lines,
242		})
243	}
244	return c.emit(d, func(w io.Writer) {
245		for _, h := range d.Hunks {
246			for i, line := range h.Lines {
247				fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line)
248			}
249		}
250	})
251}
252
253// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
254// off argv. Positionals are returned in order so each command can name them
255// in its own usage message.
256func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
257	f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage})
258	if err != nil {
259		return nil, "", c.fail(protocol.ExitUsage, "%v", err)
260	}
261	pos, ref = f.Pos, f.Value("--ref")
262	return pos, ref, -1
263}
264
265// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
266// "..", no leading slash. git would resolve those against the work tree.
267func cleanRepoPath(p string) (string, bool) {
268	p = strings.Trim(p, "/")
269	if p == "" {
270		return "", true
271	}
272	cleaned := path.Clean(p)
273	if cleaned == "." || cleaned == ".." ||
274		strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
275		return "", false
276	}
277	return cleaned, true
278}
279
280// entryOut is one tree entry. The sha lets a client cache by object id
281// rather than by path and ref, which is what makes an offline client
282// tractable.
283type entryOut struct {
284	Name string `json:"name"`
285	Type string `json:"type"` // blob | tree
286	Mode string `json:"mode"`
287	SHA  string `json:"sha"`
288	Size int64  `json:"size,omitempty"` // absent for trees
289}
290
291func runRepoTree(c *Ctx, args []string) int {
292	pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
293	if code >= 0 {
294		return code
295	}
296	if len(pos) == 0 {
297		return c.usage()
298	}
299	repo, code := resolveRepo(c, pos[0], policy.CanRead)
300	if code >= 0 {
301		return code
302	}
303	dirPath := ""
304	if len(pos) == 2 {
305		var ok bool
306		if dirPath, ok = cleanRepoPath(pos[1]); !ok {
307			return c.fail(protocol.ExitUsage, "path must stay inside the repository")
308		}
309	}
310	if ref == "" {
311		ref = repo.DefaultBranch
312	}
313	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
314	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
315		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
316	}
317	entries, err := gitutil.ListTree(dir, ref, dirPath)
318	if err != nil {
319		return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
320	}
321
322	type out struct {
323		Path    string     `json:"path"`
324		Ref     string     `json:"ref"`
325		Dir     string     `json:"dir"`
326		Entries []entryOut `json:"entries"`
327	}
328	d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
329	for _, e := range entries {
330		eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
331		if e.Type != "tree" && e.Size >= 0 {
332			eo.Size = e.Size
333		}
334		d.Entries = append(d.Entries, eo)
335	}
336	return c.emitView(d, func(w io.Writer) {
337		tb := c.table(w, "SHA", "SIZE", "NAME")
338		for _, e := range d.Entries {
339			name := e.Name
340			if e.Type == "tree" {
341				name += "/"
342			}
343			size := cText("-")
344			if e.Type != "tree" {
345				size = cSize(e.Size)
346			}
347			tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name))
348		}
349		tb.flush()
350	}, func() screen {
351		title := repo.Path()
352		if dirPath != "" {
353			title += "/" + dirPath
354		}
355		rows := make([]row, len(d.Entries))
356		firstFile := ""
357		for i, e := range d.Entries {
358			name, size := cText(e.Name), cText("")
359			if e.Type == "tree" {
360				name = cMark(e.Name+"/", sgrBlue)
361			} else {
362				size = cSize(e.Size)
363				if firstFile == "" {
364					firstFile = path.Join(dirPath, e.Name)
365				}
366			}
367			rows[i] = rowOf(name, size, cMeta(e.SHA[:min(10, len(e.SHA))]))
368		}
369		s := listScreen(title+" at "+ref, rows)
370		if firstFile != "" {
371			s.actions = append(s.actions, action{"Read", []string{"repo", "cat", repo.Path(), firstFile, "--ref", ref}})
372		}
373		logArgs := []string{"repo", "log", repo.Path(), "--ref", ref}
374		if dirPath != "" {
375			logArgs = append(logArgs, "--path", dirPath)
376		}
377		s.actions = append(s.actions, action{"Read", logArgs})
378		return s
379	})
380}
381
382func runRepoReadme(c *Ctx, args []string) int {
383	pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1)
384	if code >= 0 {
385		return code
386	}
387	if len(pos) != 1 {
388		return c.usage()
389	}
390	repo, code := resolveRepo(c, pos[0], policy.CanRead)
391	if code >= 0 {
392		return code
393	}
394	if ref == "" {
395		ref = repo.DefaultBranch
396	}
397	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
398	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
399		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
400	}
401	entries, err := gitutil.ListTree(dir, ref, "")
402	if err != nil {
403		return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref)
404	}
405	name := PickReadme(entries)
406	if name == "" {
407		return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref)
408	}
409	limit := c.Cfg.Limits.MaxBlobBytes
410	data, err := gitutil.ReadBlob(dir, ref, name, limit+1)
411	if err != nil {
412		return c.fail(protocol.ExitFailure, "%v", err)
413	}
414	truncated := int64(len(data)) > limit
415	if truncated {
416		data = data[:limit]
417	}
418	binary := gitutil.IsBinary(data)
419
420	type out struct {
421		Path      string `json:"path"`
422		Ref       string `json:"ref"`
423		File      string `json:"file"`
424		Size      int    `json:"size"`
425		Binary    bool   `json:"binary"`
426		Truncated bool   `json:"truncated,omitempty"`
427		Content   string `json:"content,omitempty"`
428		Base64    string `json:"base64,omitempty"`
429	}
430	d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data),
431		Binary: binary, Truncated: truncated}
432	if binary {
433		d.Base64 = base64.StdEncoding.EncodeToString(data)
434	} else {
435		d.Content = string(data)
436	}
437	return c.emit(d, func(w io.Writer) {
438		if binary {
439			fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", name, len(data))
440			return
441		}
442		w.Write(data)
443		if len(data) > 0 && data[len(data)-1] != '\n' {
444			fmt.Fprintln(w)
445		}
446	})
447}
448
449// readmeRank orders competing README files: richer renderers win.
450var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
451
452// PickReadme returns the best README-ish blob in a tree listing: any
453// file named "readme" or "readme.<ext>" (case-insensitive), preferring
454// formats we can render richly.
455func PickReadme(entries []gitutil.TreeEntry) string {
456	best, bestRank := "", 1<<30
457	for _, e := range entries {
458		if e.Type != "blob" {
459			continue
460		}
461		lower := strings.ToLower(e.Name)
462		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
463			continue
464		}
465		rank, ok := readmeRank[path.Ext(lower)]
466		if !ok {
467			rank = 10 // plaintext fallback
468		}
469		if rank < bestRank {
470			best, bestRank = e.Name, rank
471		}
472	}
473	return best
474}
475
476func runRepoCat(c *Ctx, args []string) int {
477	pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
478	if code >= 0 {
479		return code
480	}
481	if len(pos) != 2 {
482		return c.usage()
483	}
484	repo, code := resolveRepo(c, pos[0], policy.CanRead)
485	if code >= 0 {
486		return code
487	}
488	filePath, ok := cleanRepoPath(pos[1])
489	if !ok || filePath == "" {
490		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
491	}
492	if ref == "" {
493		ref = repo.DefaultBranch
494	}
495	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
496	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
497		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
498	}
499	// One byte over the cap distinguishes "exactly at the limit" from
500	// "truncated", so a client is told which it got.
501	limit := c.Cfg.Limits.MaxBlobBytes
502	data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
503	if err != nil {
504		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
505	}
506	truncated := int64(len(data)) > limit
507	if truncated {
508		data = data[:limit]
509	}
510	binary := gitutil.IsBinary(data)
511
512	type out struct {
513		Path      string `json:"path"`
514		Ref       string `json:"ref"`
515		File      string `json:"file"`
516		Size      int    `json:"size"`
517		Binary    bool   `json:"binary"`
518		Truncated bool   `json:"truncated,omitempty"`
519		// Exactly one of these is set: text for UTF-8-safe content,
520		// base64 for anything else, so a client never has to guess.
521		Content string `json:"content,omitempty"`
522		Base64  string `json:"base64,omitempty"`
523	}
524	d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
525		Binary: binary, Truncated: truncated}
526	if binary {
527		d.Base64 = base64.StdEncoding.EncodeToString(data)
528	} else {
529		d.Content = string(data)
530	}
531	return c.emit(d, func(w io.Writer) {
532		if binary {
533			fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
534			return
535		}
536		w.Write(data)
537		if len(data) > 0 && data[len(data)-1] != '\n' {
538			fmt.Fprintln(w)
539		}
540	})
541}