internal/control/read.go
541 lines · 16241 bytes
12 symbols in this file
1package control
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "io"
8 "path"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/policy"
16 "gitbay.org/gitbay/internal/protocol"
17)
18
19func init() {
20 register(Command{
21 Path: []string{"repo", "tree"},
22 Summary: "list a directory",
23 Usage: "repo tree <owner/name> [<path>] [--ref <ref>]",
24 Flags: []Flag{
25 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
26 },
27 Examples: []string{"repo tree krz/gitbay internal/control"},
28 ReadOnly: true,
29 Run: runRepoTree,
30 })
31 register(Command{
32 Path: []string{"repo", "cat"},
33 Summary: "read a file",
34 Usage: "repo cat <owner/name> <path> [--ref <ref>]",
35 Flags: []Flag{
36 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
37 },
38 Examples: []string{"repo cat krz/gitbay internal/control/control.go"},
39 ReadOnly: true,
40 Run: runRepoCat,
41 })
42 register(Command{
43 Path: []string{"repo", "blame"},
44 Summary: "attribute lines to commits",
45 Usage: "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]",
46 Flags: []Flag{
47 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
48 {"--from", "<n>", "first line to attribute", "1"},
49 {"--to", "<n>", "last line to attribute", "the file's last line"},
50 },
51 Examples: []string{"repo blame krz/gitbay internal/control/control.go --from 1 --to 40"},
52 ReadOnly: true,
53 Run: runRepoBlame,
54 })
55 register(Command{
56 Path: []string{"repo", "readme"},
57 Summary: "print a repository's README",
58 Usage: "repo readme <owner/name> [--ref <ref>]",
59 Flags: []Flag{
60 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
61 },
62 Examples: []string{"repo readme krz/gitbay"},
63 ReadOnly: true,
64 Run: runRepoReadme,
65 })
66 register(Command{
67 Path: []string{"repo", "refs"},
68 Summary: "list branches and tags",
69 Usage: "repo refs <owner/name>",
70 Examples: []string{"repo refs krz/gitbay"},
71 ReadOnly: true,
72 Run: runRepoRefs,
73 })
74}
75
76func runRepoRefs(c *Ctx, args []string) int {
77 if len(args) != 1 {
78 return c.usage()
79 }
80 repo, code := resolveRepo(c, args[0], policy.CanRead)
81 if code >= 0 {
82 return code
83 }
84 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
85 branches, err := gitutil.Refs(dir, "heads")
86 if err != nil {
87 return c.fail(protocol.ExitFailure, "listing branches: %v", err)
88 }
89 tags, err := gitutil.Refs(dir, "tags")
90 if err != nil {
91 return c.fail(protocol.ExitFailure, "listing tags: %v", err)
92 }
93 type refOut struct {
94 Name string `json:"name"`
95 SHA string `json:"sha"`
96 }
97 type out struct {
98 Branches []refOut `json:"branches"`
99 Tags []refOut `json:"tags"`
100 }
101 d := out{Branches: []refOut{}, Tags: []refOut{}}
102 for _, ref := range branches {
103 d.Branches = append(d.Branches, refOut{Name: ref.Name, SHA: ref.SHA})
104 }
105 for _, ref := range tags {
106 d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA})
107 }
108 // Tags read newest version first; the JSON keeps git's order.
109 sorted := slices.Clone(tags)
110 gitutil.SortVersions(sorted)
111 return c.emitView(d, func(w io.Writer) {
112 tb := c.table(w, "KIND", "NAME", "SHA")
113 for _, ref := range d.Branches {
114 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
115 }
116 for _, ref := range sorted {
117 tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
118 }
119 tb.flush()
120 }, func() screen {
121 branches := section{title: "Branches", n: len(d.Branches)}
122 for _, r := range d.Branches {
123 if r.Name == repo.DefaultBranch {
124 branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA), "default")))
125 }
126 }
127 for _, r := range d.Branches {
128 if r.Name != repo.DefaultBranch {
129 branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
130 }
131 }
132 tags := section{title: "Tags", n: len(sorted)}
133 for _, r := range sorted {
134 tags.rows = append(tags.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
135 }
136 return screen{sections: []section{branches, tags}, actions: []action{
137 {"Read", []string{"repo", "log", repo.Path(), "--ref", repo.DefaultBranch}},
138 }}
139 })
140}
141
142// BlameSpan caps one blame request, and is the page size the web renders.
143// An unbounded blame on a large file is a slow query for every surface.
144const BlameSpan = 1000
145
146func runRepoBlame(c *Ctx, args []string) int {
147 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage})
148 if err != nil {
149 return c.fail(protocol.ExitUsage, "%v", err)
150 }
151 rest, ref := f.Pos, f.Value("--ref")
152 from, to := 0, 0
153 for name, dst := range map[string]*int{"--from": &from, "--to": &to} {
154 if !f.Has(name) {
155 continue
156 }
157 n, err := strconv.Atoi(f.Value(name))
158 if err != nil || n < 1 {
159 return c.fail(protocol.ExitUsage, "%s must be a positive line number", name)
160 }
161 *dst = n
162 }
163 if len(rest) != 2 {
164 return c.usage()
165 }
166 repo, code := resolveRepo(c, rest[0], policy.CanRead)
167 if code >= 0 {
168 return code
169 }
170 filePath, ok := cleanRepoPath(rest[1])
171 if !ok || filePath == "" {
172 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
173 }
174 if ref == "" {
175 ref = repo.DefaultBranch
176 }
177 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
178 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
179 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
180 }
181 data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes)
182 if err != nil {
183 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
184 }
185 if gitutil.IsBinary(data) {
186 return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath)
187 }
188 total := bytes.Count(data, []byte("\n"))
189 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
190 total++
191 }
192 if total == 0 {
193 return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref)
194 }
195 if from == 0 {
196 from = 1
197 }
198 if from > total {
199 return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total)
200 }
201 if to == 0 || to > total {
202 to = total
203 }
204 if to < from {
205 return c.fail(protocol.ExitUsage, "--to must not precede --from")
206 }
207 // One span per call; a client pages with --from/--to.
208 if to-from+1 > BlameSpan {
209 to = from + BlameSpan - 1
210 }
211 raw, err := gitutil.Blame(dir, ref, filePath, from, to)
212 if err != nil {
213 return c.fail(protocol.ExitFailure, "%v", err)
214 }
215
216 type hunkOut struct {
217 SHA string `json:"sha"`
218 AuthorName string `json:"author_name"`
219 AuthorEmail string `json:"author_email"`
220 Date string `json:"date"`
221 Summary string `json:"summary"`
222 StartLine int `json:"start_line"`
223 Lines []string `json:"lines"`
224 }
225 type out struct {
226 Path string `json:"path"`
227 Ref string `json:"ref"`
228 File string `json:"file"`
229 From int `json:"from"`
230 To int `json:"to"`
231 TotalLines int `json:"total_lines"`
232 Hunks []hunkOut `json:"hunks"`
233 }
234 d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to,
235 TotalLines: total, Hunks: []hunkOut{}}
236 for _, h := range raw {
237 d.Hunks = append(d.Hunks, hunkOut{
238 SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail,
239 Date: time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339),
240 Summary: h.Summary,
241 StartLine: h.StartLine, Lines: h.Lines,
242 })
243 }
244 return c.emit(d, func(w io.Writer) {
245 for _, h := range d.Hunks {
246 for i, line := range h.Lines {
247 fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line)
248 }
249 }
250 })
251}
252
253// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
254// off argv. Positionals are returned in order so each command can name them
255// in its own usage message.
256func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
257 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage})
258 if err != nil {
259 return nil, "", c.fail(protocol.ExitUsage, "%v", err)
260 }
261 pos, ref = f.Pos, f.Value("--ref")
262 return pos, ref, -1
263}
264
265// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
266// "..", no leading slash. git would resolve those against the work tree.
267func cleanRepoPath(p string) (string, bool) {
268 p = strings.Trim(p, "/")
269 if p == "" {
270 return "", true
271 }
272 cleaned := path.Clean(p)
273 if cleaned == "." || cleaned == ".." ||
274 strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
275 return "", false
276 }
277 return cleaned, true
278}
279
280// entryOut is one tree entry. The sha lets a client cache by object id
281// rather than by path and ref, which is what makes an offline client
282// tractable.
283type entryOut struct {
284 Name string `json:"name"`
285 Type string `json:"type"` // blob | tree
286 Mode string `json:"mode"`
287 SHA string `json:"sha"`
288 Size int64 `json:"size,omitempty"` // absent for trees
289}
290
291func runRepoTree(c *Ctx, args []string) int {
292 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
293 if code >= 0 {
294 return code
295 }
296 if len(pos) == 0 {
297 return c.usage()
298 }
299 repo, code := resolveRepo(c, pos[0], policy.CanRead)
300 if code >= 0 {
301 return code
302 }
303 dirPath := ""
304 if len(pos) == 2 {
305 var ok bool
306 if dirPath, ok = cleanRepoPath(pos[1]); !ok {
307 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
308 }
309 }
310 if ref == "" {
311 ref = repo.DefaultBranch
312 }
313 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
314 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
315 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
316 }
317 entries, err := gitutil.ListTree(dir, ref, dirPath)
318 if err != nil {
319 return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
320 }
321
322 type out struct {
323 Path string `json:"path"`
324 Ref string `json:"ref"`
325 Dir string `json:"dir"`
326 Entries []entryOut `json:"entries"`
327 }
328 d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
329 for _, e := range entries {
330 eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
331 if e.Type != "tree" && e.Size >= 0 {
332 eo.Size = e.Size
333 }
334 d.Entries = append(d.Entries, eo)
335 }
336 return c.emitView(d, func(w io.Writer) {
337 tb := c.table(w, "SHA", "SIZE", "NAME")
338 for _, e := range d.Entries {
339 name := e.Name
340 if e.Type == "tree" {
341 name += "/"
342 }
343 size := cText("-")
344 if e.Type != "tree" {
345 size = cSize(e.Size)
346 }
347 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name))
348 }
349 tb.flush()
350 }, func() screen {
351 title := repo.Path()
352 if dirPath != "" {
353 title += "/" + dirPath
354 }
355 rows := make([]row, len(d.Entries))
356 firstFile := ""
357 for i, e := range d.Entries {
358 name, size := cText(e.Name), cText("")
359 if e.Type == "tree" {
360 name = cMark(e.Name+"/", sgrBlue)
361 } else {
362 size = cSize(e.Size)
363 if firstFile == "" {
364 firstFile = path.Join(dirPath, e.Name)
365 }
366 }
367 rows[i] = rowOf(name, size, cMeta(e.SHA[:min(10, len(e.SHA))]))
368 }
369 s := listScreen(title+" at "+ref, rows)
370 if firstFile != "" {
371 s.actions = append(s.actions, action{"Read", []string{"repo", "cat", repo.Path(), firstFile, "--ref", ref}})
372 }
373 logArgs := []string{"repo", "log", repo.Path(), "--ref", ref}
374 if dirPath != "" {
375 logArgs = append(logArgs, "--path", dirPath)
376 }
377 s.actions = append(s.actions, action{"Read", logArgs})
378 return s
379 })
380}
381
382func runRepoReadme(c *Ctx, args []string) int {
383 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1)
384 if code >= 0 {
385 return code
386 }
387 if len(pos) != 1 {
388 return c.usage()
389 }
390 repo, code := resolveRepo(c, pos[0], policy.CanRead)
391 if code >= 0 {
392 return code
393 }
394 if ref == "" {
395 ref = repo.DefaultBranch
396 }
397 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
398 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
399 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
400 }
401 entries, err := gitutil.ListTree(dir, ref, "")
402 if err != nil {
403 return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref)
404 }
405 name := PickReadme(entries)
406 if name == "" {
407 return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref)
408 }
409 limit := c.Cfg.Limits.MaxBlobBytes
410 data, err := gitutil.ReadBlob(dir, ref, name, limit+1)
411 if err != nil {
412 return c.fail(protocol.ExitFailure, "%v", err)
413 }
414 truncated := int64(len(data)) > limit
415 if truncated {
416 data = data[:limit]
417 }
418 binary := gitutil.IsBinary(data)
419
420 type out struct {
421 Path string `json:"path"`
422 Ref string `json:"ref"`
423 File string `json:"file"`
424 Size int `json:"size"`
425 Binary bool `json:"binary"`
426 Truncated bool `json:"truncated,omitempty"`
427 Content string `json:"content,omitempty"`
428 Base64 string `json:"base64,omitempty"`
429 }
430 d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data),
431 Binary: binary, Truncated: truncated}
432 if binary {
433 d.Base64 = base64.StdEncoding.EncodeToString(data)
434 } else {
435 d.Content = string(data)
436 }
437 return c.emit(d, func(w io.Writer) {
438 if binary {
439 fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", name, len(data))
440 return
441 }
442 w.Write(data)
443 if len(data) > 0 && data[len(data)-1] != '\n' {
444 fmt.Fprintln(w)
445 }
446 })
447}
448
449// readmeRank orders competing README files: richer renderers win.
450var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
451
452// PickReadme returns the best README-ish blob in a tree listing: any
453// file named "readme" or "readme.<ext>" (case-insensitive), preferring
454// formats we can render richly.
455func PickReadme(entries []gitutil.TreeEntry) string {
456 best, bestRank := "", 1<<30
457 for _, e := range entries {
458 if e.Type != "blob" {
459 continue
460 }
461 lower := strings.ToLower(e.Name)
462 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
463 continue
464 }
465 rank, ok := readmeRank[path.Ext(lower)]
466 if !ok {
467 rank = 10 // plaintext fallback
468 }
469 if rank < bestRank {
470 best, bestRank = e.Name, rank
471 }
472 }
473 return best
474}
475
476func runRepoCat(c *Ctx, args []string) int {
477 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
478 if code >= 0 {
479 return code
480 }
481 if len(pos) != 2 {
482 return c.usage()
483 }
484 repo, code := resolveRepo(c, pos[0], policy.CanRead)
485 if code >= 0 {
486 return code
487 }
488 filePath, ok := cleanRepoPath(pos[1])
489 if !ok || filePath == "" {
490 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
491 }
492 if ref == "" {
493 ref = repo.DefaultBranch
494 }
495 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
496 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
497 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
498 }
499 // One byte over the cap distinguishes "exactly at the limit" from
500 // "truncated", so a client is told which it got.
501 limit := c.Cfg.Limits.MaxBlobBytes
502 data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
503 if err != nil {
504 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
505 }
506 truncated := int64(len(data)) > limit
507 if truncated {
508 data = data[:limit]
509 }
510 binary := gitutil.IsBinary(data)
511
512 type out struct {
513 Path string `json:"path"`
514 Ref string `json:"ref"`
515 File string `json:"file"`
516 Size int `json:"size"`
517 Binary bool `json:"binary"`
518 Truncated bool `json:"truncated,omitempty"`
519 // Exactly one of these is set: text for UTF-8-safe content,
520 // base64 for anything else, so a client never has to guess.
521 Content string `json:"content,omitempty"`
522 Base64 string `json:"base64,omitempty"`
523 }
524 d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
525 Binary: binary, Truncated: truncated}
526 if binary {
527 d.Base64 = base64.StdEncoding.EncodeToString(data)
528 } else {
529 d.Content = string(data)
530 }
531 return c.emit(d, func(w io.Writer) {
532 if binary {
533 fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
534 return
535 }
536 w.Write(data)
537 if len(data) > 0 && data[len(data)-1] != '\n' {
538 fmt.Fprintln(w)
539 }
540 })
541}