internal/control/read.go

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

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