internal/control/read.go

7effe3feb4612cffb5ed38c02300299559aa8971
gitbay/internal/control/read.go history · blame · raw

371 lines · 11026 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		ReadOnly: true,
 24		Run:      runRepoTree,
 25	})
 26	register(Command{
 27		Path:     []string{"repo", "cat"},
 28		Summary:  "read a file",
 29		Usage:    "repo cat <owner/name> <path> [--ref <ref>]",
 30		ReadOnly: true,
 31		Run:      runRepoCat,
 32	})
 33	register(Command{
 34		Path:     []string{"repo", "blame"},
 35		Summary:  "attribute lines to commits",
 36		Usage:    "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]",
 37		ReadOnly: true,
 38		Run:      runRepoBlame,
 39	})
 40	register(Command{
 41		Path:     []string{"repo", "refs"},
 42		Summary:  "list branches and tags",
 43		Usage:    "repo refs <owner/name>",
 44		ReadOnly: true,
 45		Run:      runRepoRefs,
 46	})
 47}
 48
 49func runRepoRefs(c *Ctx, args []string) int {
 50	if len(args) != 1 {
 51		return c.fail(protocol.ExitUsage, "usage: repo refs <owner/name>")
 52	}
 53	repo, code := resolveRepo(c, args[0], policy.CanRead)
 54	if code >= 0 {
 55		return code
 56	}
 57	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 58	branches, err := gitutil.Refs(dir, "heads")
 59	if err != nil {
 60		return c.fail(protocol.ExitFailure, "listing branches: %v", err)
 61	}
 62	tags, err := gitutil.Refs(dir, "tags")
 63	if err != nil {
 64		return c.fail(protocol.ExitFailure, "listing tags: %v", err)
 65	}
 66	type refOut struct {
 67		Name string `json:"name"`
 68		SHA  string `json:"sha"`
 69	}
 70	type out struct {
 71		Branches []refOut `json:"branches"`
 72		Tags     []refOut `json:"tags"`
 73	}
 74	d := out{Branches: []refOut{}, Tags: []refOut{}}
 75	for _, ref := range branches {
 76		d.Branches = append(d.Branches, refOut{Name: ref.Name, SHA: ref.SHA})
 77	}
 78	for _, ref := range tags {
 79		d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA})
 80	}
 81	return c.emit(d, func(w io.Writer) {
 82		for _, ref := range d.Branches {
 83			fmt.Fprintf(w, "branch\t%s\t%.10s\n", ref.Name, ref.SHA)
 84		}
 85		for _, ref := range d.Tags {
 86			fmt.Fprintf(w, "tag\t%s\t%.10s\n", ref.Name, ref.SHA)
 87		}
 88	})
 89}
 90
 91// BlameSpan caps one blame request, and is the page size the web renders.
 92// An unbounded blame on a large file is a slow query for every surface.
 93const BlameSpan = 1000
 94
 95func runRepoBlame(c *Ctx, args []string) int {
 96	const usage = "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]"
 97	f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: usage})
 98	if err != nil {
 99		return c.fail(protocol.ExitUsage, "%v", err)
100	}
101	rest, ref := f.Pos, f.Value("--ref")
102	from, to := 0, 0
103	for name, dst := range map[string]*int{"--from": &from, "--to": &to} {
104		if !f.Has(name) {
105			continue
106		}
107		n, err := strconv.Atoi(f.Value(name))
108		if err != nil || n < 1 {
109			return c.fail(protocol.ExitUsage, "%s must be a positive line number", name)
110		}
111		*dst = n
112	}
113	if len(rest) != 2 {
114		return c.fail(protocol.ExitUsage, "usage: %s", usage)
115	}
116	repo, code := resolveRepo(c, rest[0], policy.CanRead)
117	if code >= 0 {
118		return code
119	}
120	filePath, ok := cleanRepoPath(rest[1])
121	if !ok || filePath == "" {
122		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
123	}
124	if ref == "" {
125		ref = repo.DefaultBranch
126	}
127	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
128	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
129		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
130	}
131	data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes)
132	if err != nil {
133		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
134	}
135	if gitutil.IsBinary(data) {
136		return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath)
137	}
138	total := bytes.Count(data, []byte("\n"))
139	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
140		total++
141	}
142	if total == 0 {
143		return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref)
144	}
145	if from == 0 {
146		from = 1
147	}
148	if from > total {
149		return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total)
150	}
151	if to == 0 || to > total {
152		to = total
153	}
154	if to < from {
155		return c.fail(protocol.ExitUsage, "--to must not precede --from")
156	}
157	// One span per call; a client pages with --from/--to.
158	if to-from+1 > BlameSpan {
159		to = from + BlameSpan - 1
160	}
161	raw, err := gitutil.Blame(dir, ref, filePath, from, to)
162	if err != nil {
163		return c.fail(protocol.ExitFailure, "%v", err)
164	}
165
166	type hunkOut struct {
167		SHA         string   `json:"sha"`
168		AuthorName  string   `json:"author_name"`
169		AuthorEmail string   `json:"author_email"`
170		Date        string   `json:"date"`
171		Summary     string   `json:"summary"`
172		StartLine   int      `json:"start_line"`
173		Lines       []string `json:"lines"`
174	}
175	type out struct {
176		Path       string    `json:"path"`
177		Ref        string    `json:"ref"`
178		File       string    `json:"file"`
179		From       int       `json:"from"`
180		To         int       `json:"to"`
181		TotalLines int       `json:"total_lines"`
182		Hunks      []hunkOut `json:"hunks"`
183	}
184	d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to,
185		TotalLines: total, Hunks: []hunkOut{}}
186	for _, h := range raw {
187		d.Hunks = append(d.Hunks, hunkOut{
188			SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail,
189			Date:      time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339),
190			Summary:   h.Summary,
191			StartLine: h.StartLine, Lines: h.Lines,
192		})
193	}
194	return c.emit(d, func(w io.Writer) {
195		for _, h := range d.Hunks {
196			for i, line := range h.Lines {
197				fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line)
198			}
199		}
200	})
201}
202
203// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
204// off argv. Positionals are returned in order so each command can name them
205// in its own usage message.
206func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
207	f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: usage})
208	if err != nil {
209		return nil, "", c.fail(protocol.ExitUsage, "%v", err)
210	}
211	pos, ref = f.Pos, f.Value("--ref")
212	return pos, ref, -1
213}
214
215// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
216// "..", no leading slash. git would resolve those against the work tree.
217func cleanRepoPath(p string) (string, bool) {
218	p = strings.Trim(p, "/")
219	if p == "" {
220		return "", true
221	}
222	cleaned := path.Clean(p)
223	if cleaned == "." || cleaned == ".." ||
224		strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
225		return "", false
226	}
227	return cleaned, true
228}
229
230// entryOut is one tree entry. The sha lets a client cache by object id
231// rather than by path and ref, which is what makes an offline client
232// tractable.
233type entryOut struct {
234	Name string `json:"name"`
235	Type string `json:"type"` // blob | tree
236	Mode string `json:"mode"`
237	SHA  string `json:"sha"`
238	Size int64  `json:"size,omitempty"` // absent for trees
239}
240
241func runRepoTree(c *Ctx, args []string) int {
242	const usage = "repo tree <owner/name> [<path>] [--ref <ref>]"
243	pos, ref, code := readArgs(c, args, usage, 2)
244	if code >= 0 {
245		return code
246	}
247	if len(pos) == 0 {
248		return c.fail(protocol.ExitUsage, "usage: %s", usage)
249	}
250	repo, code := resolveRepo(c, pos[0], policy.CanRead)
251	if code >= 0 {
252		return code
253	}
254	dirPath := ""
255	if len(pos) == 2 {
256		var ok bool
257		if dirPath, ok = cleanRepoPath(pos[1]); !ok {
258			return c.fail(protocol.ExitUsage, "path must stay inside the repository")
259		}
260	}
261	if ref == "" {
262		ref = repo.DefaultBranch
263	}
264	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
265	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
266		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
267	}
268	entries, err := gitutil.ListTree(dir, ref, dirPath)
269	if err != nil {
270		return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
271	}
272
273	type out struct {
274		Path    string     `json:"path"`
275		Ref     string     `json:"ref"`
276		Dir     string     `json:"dir"`
277		Entries []entryOut `json:"entries"`
278	}
279	d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
280	for _, e := range entries {
281		eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
282		if e.Type != "tree" && e.Size >= 0 {
283			eo.Size = e.Size
284		}
285		d.Entries = append(d.Entries, eo)
286	}
287	return c.emit(d, func(w io.Writer) {
288		for _, e := range d.Entries {
289			name := e.Name
290			if e.Type == "tree" {
291				name += "/"
292			}
293			fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name)
294		}
295	})
296}
297
298func sizeCol(e entryOut) string {
299	if e.Type == "tree" {
300		return "-"
301	}
302	return fmt.Sprintf("%d", e.Size)
303}
304
305func runRepoCat(c *Ctx, args []string) int {
306	const usage = "repo cat <owner/name> <path> [--ref <ref>]"
307	pos, ref, code := readArgs(c, args, usage, 2)
308	if code >= 0 {
309		return code
310	}
311	if len(pos) != 2 {
312		return c.fail(protocol.ExitUsage, "usage: %s", usage)
313	}
314	repo, code := resolveRepo(c, pos[0], policy.CanRead)
315	if code >= 0 {
316		return code
317	}
318	filePath, ok := cleanRepoPath(pos[1])
319	if !ok || filePath == "" {
320		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
321	}
322	if ref == "" {
323		ref = repo.DefaultBranch
324	}
325	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
326	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
327		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
328	}
329	// One byte over the cap distinguishes "exactly at the limit" from
330	// "truncated", so a client is told which it got.
331	limit := c.Cfg.Limits.MaxBlobBytes
332	data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
333	if err != nil {
334		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
335	}
336	truncated := int64(len(data)) > limit
337	if truncated {
338		data = data[:limit]
339	}
340	binary := gitutil.IsBinary(data)
341
342	type out struct {
343		Path      string `json:"path"`
344		Ref       string `json:"ref"`
345		File      string `json:"file"`
346		Size      int    `json:"size"`
347		Binary    bool   `json:"binary"`
348		Truncated bool   `json:"truncated,omitempty"`
349		// Exactly one of these is set: text for UTF-8-safe content,
350		// base64 for anything else, so a client never has to guess.
351		Content string `json:"content,omitempty"`
352		Base64  string `json:"base64,omitempty"`
353	}
354	d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
355		Binary: binary, Truncated: truncated}
356	if binary {
357		d.Base64 = base64.StdEncoding.EncodeToString(data)
358	} else {
359		d.Content = string(data)
360	}
361	return c.emit(d, func(w io.Writer) {
362		if binary {
363			fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
364			return
365		}
366		w.Write(data)
367		if len(data) > 0 && data[len(data)-1] != '\n' {
368			fmt.Fprintln(w)
369		}
370	})
371}