internal/control/read.go

0338e6ace3de199d5fc383852649919b68ef3e42
gitbay/internal/control/read.go history · blame · raw

492 lines · 14507 bytes

  1package control
  2
  3import (
  4	"bytes"
  5	"encoding/base64"
  6	"fmt"
  7	"io"
  8	"path"
  9	"strconv"
 10	"strings"
 11	"time"
 12
 13	"gitbay.org/gitbay/internal/gitutil"
 14	"gitbay.org/gitbay/internal/policy"
 15	"gitbay.org/gitbay/internal/protocol"
 16)
 17
 18func init() {
 19	register(Command{
 20		Path:    []string{"repo", "tree"},
 21		Summary: "list a directory",
 22		Usage:   "repo tree <owner/name> [<path>] [--ref <ref>]",
 23		Flags: []Flag{
 24			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
 25		},
 26		Examples: []string{"repo tree krz/gitbay internal/control"},
 27		ReadOnly: true,
 28		Run:      runRepoTree,
 29	})
 30	register(Command{
 31		Path:    []string{"repo", "cat"},
 32		Summary: "read a file",
 33		Usage:   "repo cat <owner/name> <path> [--ref <ref>]",
 34		Flags: []Flag{
 35			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
 36		},
 37		Examples: []string{"repo cat krz/gitbay internal/control/control.go"},
 38		ReadOnly: true,
 39		Run:      runRepoCat,
 40	})
 41	register(Command{
 42		Path:    []string{"repo", "blame"},
 43		Summary: "attribute lines to commits",
 44		Usage:   "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]",
 45		Flags: []Flag{
 46			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
 47			{"--from", "<n>", "first line to attribute", "1"},
 48			{"--to", "<n>", "last line to attribute", "the file's last line"},
 49		},
 50		Examples: []string{"repo blame krz/gitbay internal/control/control.go --from 1 --to 40"},
 51		ReadOnly: true,
 52		Run:      runRepoBlame,
 53	})
 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	})
 65	register(Command{
 66		Path:     []string{"repo", "refs"},
 67		Summary:  "list branches and tags",
 68		Usage:    "repo refs <owner/name>",
 69		Examples: []string{"repo refs krz/gitbay"},
 70		ReadOnly: true,
 71		Run:      runRepoRefs,
 72	})
 73}
 74
 75func runRepoRefs(c *Ctx, args []string) int {
 76	if len(args) != 1 {
 77		return c.usage()
 78	}
 79	repo, code := resolveRepo(c, args[0], policy.CanRead)
 80	if code >= 0 {
 81		return code
 82	}
 83	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 84	branches, err := gitutil.Refs(dir, "heads")
 85	if err != nil {
 86		return c.fail(protocol.ExitFailure, "listing branches: %v", err)
 87	}
 88	tags, err := gitutil.Refs(dir, "tags")
 89	if err != nil {
 90		return c.fail(protocol.ExitFailure, "listing tags: %v", err)
 91	}
 92	type refOut struct {
 93		Name string `json:"name"`
 94		SHA  string `json:"sha"`
 95	}
 96	type out struct {
 97		Branches []refOut `json:"branches"`
 98		Tags     []refOut `json:"tags"`
 99	}
100	d := out{Branches: []refOut{}, Tags: []refOut{}}
101	for _, ref := range branches {
102		d.Branches = append(d.Branches, refOut{Name: ref.Name, SHA: ref.SHA})
103	}
104	for _, ref := range tags {
105		d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA})
106	}
107	return c.emit(d, func(w io.Writer) {
108		tb := c.table(w, "KIND", "NAME", "SHA")
109		for _, ref := range d.Branches {
110			tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
111		}
112		for _, ref := range d.Tags {
113			tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
114		}
115		tb.flush()
116	})
117}
118
119// BlameSpan caps one blame request, and is the page size the web renders.
120// An unbounded blame on a large file is a slow query for every surface.
121const BlameSpan = 1000
122
123func runRepoBlame(c *Ctx, args []string) int {
124	f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage})
125	if err != nil {
126		return c.fail(protocol.ExitUsage, "%v", err)
127	}
128	rest, ref := f.Pos, f.Value("--ref")
129	from, to := 0, 0
130	for name, dst := range map[string]*int{"--from": &from, "--to": &to} {
131		if !f.Has(name) {
132			continue
133		}
134		n, err := strconv.Atoi(f.Value(name))
135		if err != nil || n < 1 {
136			return c.fail(protocol.ExitUsage, "%s must be a positive line number", name)
137		}
138		*dst = n
139	}
140	if len(rest) != 2 {
141		return c.usage()
142	}
143	repo, code := resolveRepo(c, rest[0], policy.CanRead)
144	if code >= 0 {
145		return code
146	}
147	filePath, ok := cleanRepoPath(rest[1])
148	if !ok || filePath == "" {
149		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
150	}
151	if ref == "" {
152		ref = repo.DefaultBranch
153	}
154	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
155	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
156		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
157	}
158	data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes)
159	if err != nil {
160		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
161	}
162	if gitutil.IsBinary(data) {
163		return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath)
164	}
165	total := bytes.Count(data, []byte("\n"))
166	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
167		total++
168	}
169	if total == 0 {
170		return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref)
171	}
172	if from == 0 {
173		from = 1
174	}
175	if from > total {
176		return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total)
177	}
178	if to == 0 || to > total {
179		to = total
180	}
181	if to < from {
182		return c.fail(protocol.ExitUsage, "--to must not precede --from")
183	}
184	// One span per call; a client pages with --from/--to.
185	if to-from+1 > BlameSpan {
186		to = from + BlameSpan - 1
187	}
188	raw, err := gitutil.Blame(dir, ref, filePath, from, to)
189	if err != nil {
190		return c.fail(protocol.ExitFailure, "%v", err)
191	}
192
193	type hunkOut struct {
194		SHA         string   `json:"sha"`
195		AuthorName  string   `json:"author_name"`
196		AuthorEmail string   `json:"author_email"`
197		Date        string   `json:"date"`
198		Summary     string   `json:"summary"`
199		StartLine   int      `json:"start_line"`
200		Lines       []string `json:"lines"`
201	}
202	type out struct {
203		Path       string    `json:"path"`
204		Ref        string    `json:"ref"`
205		File       string    `json:"file"`
206		From       int       `json:"from"`
207		To         int       `json:"to"`
208		TotalLines int       `json:"total_lines"`
209		Hunks      []hunkOut `json:"hunks"`
210	}
211	d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to,
212		TotalLines: total, Hunks: []hunkOut{}}
213	for _, h := range raw {
214		d.Hunks = append(d.Hunks, hunkOut{
215			SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail,
216			Date:      time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339),
217			Summary:   h.Summary,
218			StartLine: h.StartLine, Lines: h.Lines,
219		})
220	}
221	return c.emit(d, func(w io.Writer) {
222		for _, h := range d.Hunks {
223			for i, line := range h.Lines {
224				fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line)
225			}
226		}
227	})
228}
229
230// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
231// off argv. Positionals are returned in order so each command can name them
232// in its own usage message.
233func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
234	f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage})
235	if err != nil {
236		return nil, "", c.fail(protocol.ExitUsage, "%v", err)
237	}
238	pos, ref = f.Pos, f.Value("--ref")
239	return pos, ref, -1
240}
241
242// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
243// "..", no leading slash. git would resolve those against the work tree.
244func cleanRepoPath(p string) (string, bool) {
245	p = strings.Trim(p, "/")
246	if p == "" {
247		return "", true
248	}
249	cleaned := path.Clean(p)
250	if cleaned == "." || cleaned == ".." ||
251		strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
252		return "", false
253	}
254	return cleaned, true
255}
256
257// entryOut is one tree entry. The sha lets a client cache by object id
258// rather than by path and ref, which is what makes an offline client
259// tractable.
260type entryOut struct {
261	Name string `json:"name"`
262	Type string `json:"type"` // blob | tree
263	Mode string `json:"mode"`
264	SHA  string `json:"sha"`
265	Size int64  `json:"size,omitempty"` // absent for trees
266}
267
268func runRepoTree(c *Ctx, args []string) int {
269	pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
270	if code >= 0 {
271		return code
272	}
273	if len(pos) == 0 {
274		return c.usage()
275	}
276	repo, code := resolveRepo(c, pos[0], policy.CanRead)
277	if code >= 0 {
278		return code
279	}
280	dirPath := ""
281	if len(pos) == 2 {
282		var ok bool
283		if dirPath, ok = cleanRepoPath(pos[1]); !ok {
284			return c.fail(protocol.ExitUsage, "path must stay inside the repository")
285		}
286	}
287	if ref == "" {
288		ref = repo.DefaultBranch
289	}
290	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
291	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
292		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
293	}
294	entries, err := gitutil.ListTree(dir, ref, dirPath)
295	if err != nil {
296		return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
297	}
298
299	type out struct {
300		Path    string     `json:"path"`
301		Ref     string     `json:"ref"`
302		Dir     string     `json:"dir"`
303		Entries []entryOut `json:"entries"`
304	}
305	d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
306	for _, e := range entries {
307		eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
308		if e.Type != "tree" && e.Size >= 0 {
309			eo.Size = e.Size
310		}
311		d.Entries = append(d.Entries, eo)
312	}
313	return c.emit(d, func(w io.Writer) {
314		tb := c.table(w, "SHA", "SIZE", "NAME")
315		for _, e := range d.Entries {
316			name := e.Name
317			if e.Type == "tree" {
318				name += "/"
319			}
320			tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), cText(sizeCol(e)), cFlex(name))
321		}
322		tb.flush()
323	})
324}
325
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
393func sizeCol(e entryOut) string {
394	if e.Type == "tree" {
395		return "-"
396	}
397	return fmt.Sprintf("%d", e.Size)
398}
399
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
427func runRepoCat(c *Ctx, args []string) int {
428	pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
429	if code >= 0 {
430		return code
431	}
432	if len(pos) != 2 {
433		return c.usage()
434	}
435	repo, code := resolveRepo(c, pos[0], policy.CanRead)
436	if code >= 0 {
437		return code
438	}
439	filePath, ok := cleanRepoPath(pos[1])
440	if !ok || filePath == "" {
441		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
442	}
443	if ref == "" {
444		ref = repo.DefaultBranch
445	}
446	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
447	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
448		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
449	}
450	// One byte over the cap distinguishes "exactly at the limit" from
451	// "truncated", so a client is told which it got.
452	limit := c.Cfg.Limits.MaxBlobBytes
453	data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
454	if err != nil {
455		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
456	}
457	truncated := int64(len(data)) > limit
458	if truncated {
459		data = data[:limit]
460	}
461	binary := gitutil.IsBinary(data)
462
463	type out struct {
464		Path      string `json:"path"`
465		Ref       string `json:"ref"`
466		File      string `json:"file"`
467		Size      int    `json:"size"`
468		Binary    bool   `json:"binary"`
469		Truncated bool   `json:"truncated,omitempty"`
470		// Exactly one of these is set: text for UTF-8-safe content,
471		// base64 for anything else, so a client never has to guess.
472		Content string `json:"content,omitempty"`
473		Base64  string `json:"base64,omitempty"`
474	}
475	d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
476		Binary: binary, Truncated: truncated}
477	if binary {
478		d.Base64 = base64.StdEncoding.EncodeToString(data)
479	} else {
480		d.Content = string(data)
481	}
482	return c.emit(d, func(w io.Writer) {
483		if binary {
484			fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
485			return
486		}
487		w.Write(data)
488		if len(data) > 0 && data[len(data)-1] != '\n' {
489			fmt.Fprintln(w)
490		}
491	})
492}