internal/control/read.go

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

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