internal/control/read.go
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}