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