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