internal/control/read.go
387 lines · 11517 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 := c.parseArgs(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 := c.parseArgs(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}