internal/control/read.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/control/read.go history · blame · raw

399 lines · 11523 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	var rest []string
 98	var ref string
 99	from, to := 0, 0
100	for i := 0; i < len(args); i++ {
101		switch args[i] {
102		case "--ref", "--from", "--to":
103			if i+1 >= len(args) {
104				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
105			}
106			v := args[i+1]
107			if args[i] == "--ref" {
108				ref = v
109			} else {
110				n, err := strconv.Atoi(v)
111				if err != nil || n < 1 {
112					return c.fail(protocol.ExitUsage, "%s must be a positive line number", args[i])
113				}
114				if args[i] == "--from" {
115					from = n
116				} else {
117					to = n
118				}
119			}
120			i++
121		default:
122			if strings.HasPrefix(args[i], "--") {
123				return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
124			}
125			rest = append(rest, args[i])
126		}
127	}
128	if len(rest) != 2 {
129		return c.fail(protocol.ExitUsage, "usage: %s", usage)
130	}
131	repo, code := resolveRepo(c, rest[0], policy.CanRead)
132	if code >= 0 {
133		return code
134	}
135	filePath, ok := cleanRepoPath(rest[1])
136	if !ok || filePath == "" {
137		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
138	}
139	if ref == "" {
140		ref = repo.DefaultBranch
141	}
142	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
143	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
144		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
145	}
146	data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes)
147	if err != nil {
148		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
149	}
150	if gitutil.IsBinary(data) {
151		return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath)
152	}
153	total := bytes.Count(data, []byte("\n"))
154	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
155		total++
156	}
157	if total == 0 {
158		return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref)
159	}
160	if from == 0 {
161		from = 1
162	}
163	if from > total {
164		return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total)
165	}
166	if to == 0 || to > total {
167		to = total
168	}
169	if to < from {
170		return c.fail(protocol.ExitUsage, "--to must not precede --from")
171	}
172	// One span per call; a client pages with --from/--to.
173	if to-from+1 > BlameSpan {
174		to = from + BlameSpan - 1
175	}
176	raw, err := gitutil.Blame(dir, ref, filePath, from, to)
177	if err != nil {
178		return c.fail(protocol.ExitFailure, "%v", err)
179	}
180
181	type hunkOut struct {
182		SHA         string   `json:"sha"`
183		AuthorName  string   `json:"author_name"`
184		AuthorEmail string   `json:"author_email"`
185		Date        string   `json:"date"`
186		Summary     string   `json:"summary"`
187		StartLine   int      `json:"start_line"`
188		Lines       []string `json:"lines"`
189	}
190	type out struct {
191		Path       string    `json:"path"`
192		Ref        string    `json:"ref"`
193		File       string    `json:"file"`
194		From       int       `json:"from"`
195		To         int       `json:"to"`
196		TotalLines int       `json:"total_lines"`
197		Hunks      []hunkOut `json:"hunks"`
198	}
199	d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to,
200		TotalLines: total, Hunks: []hunkOut{}}
201	for _, h := range raw {
202		d.Hunks = append(d.Hunks, hunkOut{
203			SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail,
204			Date:      time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339),
205			Summary:   h.Summary,
206			StartLine: h.StartLine, Lines: h.Lines,
207		})
208	}
209	return c.emit(d, func(w io.Writer) {
210		for _, h := range d.Hunks {
211			for i, line := range h.Lines {
212				fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line)
213			}
214		}
215	})
216}
217
218// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
219// off argv. Positionals are returned in order so each command can name them
220// in its own usage message.
221func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
222	for i := 0; i < len(args); i++ {
223		switch args[i] {
224		case "--ref":
225			if i+1 >= len(args) {
226				return nil, "", c.fail(protocol.ExitUsage, "--ref requires a value")
227			}
228			ref = args[i+1]
229			i++
230		default:
231			if strings.HasPrefix(args[i], "--") {
232				return nil, "", c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
233			}
234			if len(pos) >= maxPos {
235				return nil, "", c.fail(protocol.ExitUsage, "usage: %s", usage)
236			}
237			pos = append(pos, args[i])
238		}
239	}
240	return pos, ref, -1
241}
242
243// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
244// "..", no leading slash. git would resolve those against the work tree.
245func cleanRepoPath(p string) (string, bool) {
246	p = strings.Trim(p, "/")
247	if p == "" {
248		return "", true
249	}
250	cleaned := path.Clean(p)
251	if cleaned == "." || cleaned == ".." ||
252		strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
253		return "", false
254	}
255	return cleaned, true
256}
257
258// entryOut is one tree entry. The sha lets a client cache by object id
259// rather than by path and ref, which is what makes an offline client
260// tractable.
261type entryOut struct {
262	Name string `json:"name"`
263	Type string `json:"type"` // blob | tree
264	Mode string `json:"mode"`
265	SHA  string `json:"sha"`
266	Size int64  `json:"size,omitempty"` // absent for trees
267}
268
269func runRepoTree(c *Ctx, args []string) int {
270	const usage = "repo tree <owner/name> [<path>] [--ref <ref>]"
271	pos, ref, code := readArgs(c, args, usage, 2)
272	if code >= 0 {
273		return code
274	}
275	if len(pos) == 0 {
276		return c.fail(protocol.ExitUsage, "usage: %s", usage)
277	}
278	repo, code := resolveRepo(c, pos[0], policy.CanRead)
279	if code >= 0 {
280		return code
281	}
282	dirPath := ""
283	if len(pos) == 2 {
284		var ok bool
285		if dirPath, ok = cleanRepoPath(pos[1]); !ok {
286			return c.fail(protocol.ExitUsage, "path must stay inside the repository")
287		}
288	}
289	if ref == "" {
290		ref = repo.DefaultBranch
291	}
292	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
293	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
294		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
295	}
296	entries, err := gitutil.ListTree(dir, ref, dirPath)
297	if err != nil {
298		return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
299	}
300
301	type out struct {
302		Path    string     `json:"path"`
303		Ref     string     `json:"ref"`
304		Dir     string     `json:"dir"`
305		Entries []entryOut `json:"entries"`
306	}
307	d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
308	for _, e := range entries {
309		eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
310		if e.Type != "tree" && e.Size >= 0 {
311			eo.Size = e.Size
312		}
313		d.Entries = append(d.Entries, eo)
314	}
315	return c.emit(d, func(w io.Writer) {
316		for _, e := range d.Entries {
317			name := e.Name
318			if e.Type == "tree" {
319				name += "/"
320			}
321			fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name)
322		}
323	})
324}
325
326func sizeCol(e entryOut) string {
327	if e.Type == "tree" {
328		return "-"
329	}
330	return fmt.Sprintf("%d", e.Size)
331}
332
333func runRepoCat(c *Ctx, args []string) int {
334	const usage = "repo cat <owner/name> <path> [--ref <ref>]"
335	pos, ref, code := readArgs(c, args, usage, 2)
336	if code >= 0 {
337		return code
338	}
339	if len(pos) != 2 {
340		return c.fail(protocol.ExitUsage, "usage: %s", usage)
341	}
342	repo, code := resolveRepo(c, pos[0], policy.CanRead)
343	if code >= 0 {
344		return code
345	}
346	filePath, ok := cleanRepoPath(pos[1])
347	if !ok || filePath == "" {
348		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
349	}
350	if ref == "" {
351		ref = repo.DefaultBranch
352	}
353	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
354	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
355		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
356	}
357	// One byte over the cap distinguishes "exactly at the limit" from
358	// "truncated", so a client is told which it got.
359	limit := c.Cfg.Limits.MaxBlobBytes
360	data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
361	if err != nil {
362		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
363	}
364	truncated := int64(len(data)) > limit
365	if truncated {
366		data = data[:limit]
367	}
368	binary := gitutil.IsBinary(data)
369
370	type out struct {
371		Path      string `json:"path"`
372		Ref       string `json:"ref"`
373		File      string `json:"file"`
374		Size      int    `json:"size"`
375		Binary    bool   `json:"binary"`
376		Truncated bool   `json:"truncated,omitempty"`
377		// Exactly one of these is set: text for UTF-8-safe content,
378		// base64 for anything else, so a client never has to guess.
379		Content string `json:"content,omitempty"`
380		Base64  string `json:"base64,omitempty"`
381	}
382	d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
383		Binary: binary, Truncated: truncated}
384	if binary {
385		d.Base64 = base64.StdEncoding.EncodeToString(data)
386	} else {
387		d.Content = string(data)
388	}
389	return c.emit(d, func(w io.Writer) {
390		if binary {
391			fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
392			return
393		}
394		w.Write(data)
395		if len(data) > 0 && data[len(data)-1] != '\n' {
396			fmt.Fprintln(w)
397		}
398	})
399}