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