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