internal/control/read.go
371 lines · 11026 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 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: usage})
98 if err != nil {
99 return c.fail(protocol.ExitUsage, "%v", err)
100 }
101 rest, ref := f.Pos, f.Value("--ref")
102 from, to := 0, 0
103 for name, dst := range map[string]*int{"--from": &from, "--to": &to} {
104 if !f.Has(name) {
105 continue
106 }
107 n, err := strconv.Atoi(f.Value(name))
108 if err != nil || n < 1 {
109 return c.fail(protocol.ExitUsage, "%s must be a positive line number", name)
110 }
111 *dst = n
112 }
113 if len(rest) != 2 {
114 return c.fail(protocol.ExitUsage, "usage: %s", usage)
115 }
116 repo, code := resolveRepo(c, rest[0], policy.CanRead)
117 if code >= 0 {
118 return code
119 }
120 filePath, ok := cleanRepoPath(rest[1])
121 if !ok || filePath == "" {
122 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
123 }
124 if ref == "" {
125 ref = repo.DefaultBranch
126 }
127 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
128 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
129 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
130 }
131 data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes)
132 if err != nil {
133 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
134 }
135 if gitutil.IsBinary(data) {
136 return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath)
137 }
138 total := bytes.Count(data, []byte("\n"))
139 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
140 total++
141 }
142 if total == 0 {
143 return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref)
144 }
145 if from == 0 {
146 from = 1
147 }
148 if from > total {
149 return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total)
150 }
151 if to == 0 || to > total {
152 to = total
153 }
154 if to < from {
155 return c.fail(protocol.ExitUsage, "--to must not precede --from")
156 }
157 // One span per call; a client pages with --from/--to.
158 if to-from+1 > BlameSpan {
159 to = from + BlameSpan - 1
160 }
161 raw, err := gitutil.Blame(dir, ref, filePath, from, to)
162 if err != nil {
163 return c.fail(protocol.ExitFailure, "%v", err)
164 }
165
166 type hunkOut struct {
167 SHA string `json:"sha"`
168 AuthorName string `json:"author_name"`
169 AuthorEmail string `json:"author_email"`
170 Date string `json:"date"`
171 Summary string `json:"summary"`
172 StartLine int `json:"start_line"`
173 Lines []string `json:"lines"`
174 }
175 type out struct {
176 Path string `json:"path"`
177 Ref string `json:"ref"`
178 File string `json:"file"`
179 From int `json:"from"`
180 To int `json:"to"`
181 TotalLines int `json:"total_lines"`
182 Hunks []hunkOut `json:"hunks"`
183 }
184 d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to,
185 TotalLines: total, Hunks: []hunkOut{}}
186 for _, h := range raw {
187 d.Hunks = append(d.Hunks, hunkOut{
188 SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail,
189 Date: time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339),
190 Summary: h.Summary,
191 StartLine: h.StartLine, Lines: h.Lines,
192 })
193 }
194 return c.emit(d, func(w io.Writer) {
195 for _, h := range d.Hunks {
196 for i, line := range h.Lines {
197 fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line)
198 }
199 }
200 })
201}
202
203// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
204// off argv. Positionals are returned in order so each command can name them
205// in its own usage message.
206func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
207 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: usage})
208 if err != nil {
209 return nil, "", c.fail(protocol.ExitUsage, "%v", err)
210 }
211 pos, ref = f.Pos, f.Value("--ref")
212 return pos, ref, -1
213}
214
215// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
216// "..", no leading slash. git would resolve those against the work tree.
217func cleanRepoPath(p string) (string, bool) {
218 p = strings.Trim(p, "/")
219 if p == "" {
220 return "", true
221 }
222 cleaned := path.Clean(p)
223 if cleaned == "." || cleaned == ".." ||
224 strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
225 return "", false
226 }
227 return cleaned, true
228}
229
230// entryOut is one tree entry. The sha lets a client cache by object id
231// rather than by path and ref, which is what makes an offline client
232// tractable.
233type entryOut struct {
234 Name string `json:"name"`
235 Type string `json:"type"` // blob | tree
236 Mode string `json:"mode"`
237 SHA string `json:"sha"`
238 Size int64 `json:"size,omitempty"` // absent for trees
239}
240
241func runRepoTree(c *Ctx, args []string) int {
242 const usage = "repo tree <owner/name> [<path>] [--ref <ref>]"
243 pos, ref, code := readArgs(c, args, usage, 2)
244 if code >= 0 {
245 return code
246 }
247 if len(pos) == 0 {
248 return c.fail(protocol.ExitUsage, "usage: %s", usage)
249 }
250 repo, code := resolveRepo(c, pos[0], policy.CanRead)
251 if code >= 0 {
252 return code
253 }
254 dirPath := ""
255 if len(pos) == 2 {
256 var ok bool
257 if dirPath, ok = cleanRepoPath(pos[1]); !ok {
258 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
259 }
260 }
261 if ref == "" {
262 ref = repo.DefaultBranch
263 }
264 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
265 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
266 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
267 }
268 entries, err := gitutil.ListTree(dir, ref, dirPath)
269 if err != nil {
270 return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
271 }
272
273 type out struct {
274 Path string `json:"path"`
275 Ref string `json:"ref"`
276 Dir string `json:"dir"`
277 Entries []entryOut `json:"entries"`
278 }
279 d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
280 for _, e := range entries {
281 eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
282 if e.Type != "tree" && e.Size >= 0 {
283 eo.Size = e.Size
284 }
285 d.Entries = append(d.Entries, eo)
286 }
287 return c.emit(d, func(w io.Writer) {
288 for _, e := range d.Entries {
289 name := e.Name
290 if e.Type == "tree" {
291 name += "/"
292 }
293 fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name)
294 }
295 })
296}
297
298func sizeCol(e entryOut) string {
299 if e.Type == "tree" {
300 return "-"
301 }
302 return fmt.Sprintf("%d", e.Size)
303}
304
305func runRepoCat(c *Ctx, args []string) int {
306 const usage = "repo cat <owner/name> <path> [--ref <ref>]"
307 pos, ref, code := readArgs(c, args, usage, 2)
308 if code >= 0 {
309 return code
310 }
311 if len(pos) != 2 {
312 return c.fail(protocol.ExitUsage, "usage: %s", usage)
313 }
314 repo, code := resolveRepo(c, pos[0], policy.CanRead)
315 if code >= 0 {
316 return code
317 }
318 filePath, ok := cleanRepoPath(pos[1])
319 if !ok || filePath == "" {
320 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
321 }
322 if ref == "" {
323 ref = repo.DefaultBranch
324 }
325 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
326 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
327 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
328 }
329 // One byte over the cap distinguishes "exactly at the limit" from
330 // "truncated", so a client is told which it got.
331 limit := c.Cfg.Limits.MaxBlobBytes
332 data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
333 if err != nil {
334 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
335 }
336 truncated := int64(len(data)) > limit
337 if truncated {
338 data = data[:limit]
339 }
340 binary := gitutil.IsBinary(data)
341
342 type out struct {
343 Path string `json:"path"`
344 Ref string `json:"ref"`
345 File string `json:"file"`
346 Size int `json:"size"`
347 Binary bool `json:"binary"`
348 Truncated bool `json:"truncated,omitempty"`
349 // Exactly one of these is set: text for UTF-8-safe content,
350 // base64 for anything else, so a client never has to guess.
351 Content string `json:"content,omitempty"`
352 Base64 string `json:"base64,omitempty"`
353 }
354 d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
355 Binary: binary, Truncated: truncated}
356 if binary {
357 d.Base64 = base64.StdEncoding.EncodeToString(data)
358 } else {
359 d.Content = string(data)
360 }
361 return c.emit(d, func(w io.Writer) {
362 if binary {
363 fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
364 return
365 }
366 w.Write(data)
367 if len(data) > 0 && data[len(data)-1] != '\n' {
368 fmt.Fprintln(w)
369 }
370 })
371}