internal/control/read.go

516 lines · 15209 bytes

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