internal/gitutil/read.go
178 lines · 5093 bytes
1package gitutil
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os/exec"
10 "strconv"
11 "strings"
12 "time"
13
14 "gitbay.org/gitbay/internal/toolpath"
15)
16
17type TreeEntry struct {
18 Mode string
19 Type string // blob | tree
20 SHA string
21 Size int64 // -1 for trees
22 Name string
23}
24
25// ListTree lists one level of the tree at ref:path.
26func ListTree(dir, ref, path string) ([]TreeEntry, error) {
27 spec := ref
28 if path != "" {
29 spec = ref + ":" + path
30 }
31 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "ls-tree", "-l", "--end-of-options", spec)
32 out, err := cmd.Output()
33 if err != nil {
34 return nil, fmt.Errorf("ls-tree %s: %w", spec, err)
35 }
36 var entries []TreeEntry
37 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
38 if line == "" {
39 continue
40 }
41 // <mode> <type> <sha> <size>\t<name>
42 meta, name, ok := strings.Cut(line, "\t")
43 if !ok {
44 continue
45 }
46 f := strings.Fields(meta)
47 if len(f) != 4 {
48 continue
49 }
50 size := int64(-1)
51 if f[3] != "-" {
52 size, _ = strconv.ParseInt(f[3], 10, 64)
53 }
54 entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name})
55 }
56 return entries, nil
57}
58
59// ReadBlob returns the contents of ref:path, capped at limit bytes.
60func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) {
61 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "blob", "--end-of-options", ref+":"+path)
62 stdout, err := cmd.StdoutPipe()
63 if err != nil {
64 return nil, err
65 }
66 if err := cmd.Start(); err != nil {
67 return nil, err
68 }
69 data, err := io.ReadAll(io.LimitReader(stdout, limit))
70 io.Copy(io.Discard, stdout) // drain so git exits cleanly
71 if werr := cmd.Wait(); werr != nil {
72 return nil, fmt.Errorf("cat-file blob %s:%s: %w", ref, path, werr)
73 }
74 return data, err
75}
76
77// ResolveRef resolves a ref or sha to a full commit sha; errors if absent.
78func ResolveRef(dir, ref string) (string, error) {
79 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}")
80 out, err := cmd.Output()
81 if err != nil {
82 return "", fmt.Errorf("unknown ref %q", ref)
83 }
84 return strings.TrimSpace(string(out)), nil
85}
86
87type Ref struct {
88 Name string
89 SHA string
90}
91
92// Refs lists branches or tags; kind is "heads" or "tags".
93func Refs(dir, kind string) ([]Ref, error) {
94 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "for-each-ref",
95 "--format=%(refname:short) %(objectname)", "refs/"+kind)
96 out, err := cmd.Output()
97 if err != nil {
98 return nil, err
99 }
100 var refs []Ref
101 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
102 if name, sha, ok := strings.Cut(line, " "); ok {
103 refs = append(refs, Ref{Name: name, SHA: sha})
104 }
105 }
106 return refs, nil
107}
108
109// Bounds on one archive: a request cannot hold git and a goroutine for
110// longer than this, or stream more than this, however large the
111// repository or however slowly the client reads (#124).
112const archiveTimeout = 2 * time.Minute
113
114var MaxArchiveBytes int64 = 512 << 20
115
116var ErrArchiveTooLarge = errors.New("archive exceeds the size limit")
117
118// Archive streams a tar.gz of ref to w, within archiveTimeout and
119// MaxArchiveBytes. Past either, git is killed and the error says which.
120func Archive(dir, ref, prefix string, w io.Writer) error {
121 ctx, cancel := context.WithTimeout(context.Background(), archiveTimeout)
122 defer cancel()
123 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", "--end-of-options", ref)
124 lw := &cappedWriter{w: w, left: MaxArchiveBytes, stop: cancel}
125 cmd.Stdout = lw
126 err := cmd.Run()
127 switch {
128 case lw.exceeded:
129 return ErrArchiveTooLarge
130 case errors.Is(ctx.Err(), context.DeadlineExceeded):
131 return fmt.Errorf("git archive: timed out after %s", archiveTimeout)
132 }
133 return err
134}
135
136// cappedWriter passes bytes through until the cap, then stops the
137// producer instead of writing a truncated tail.
138type cappedWriter struct {
139 w io.Writer
140 left int64
141 stop func()
142 exceeded bool
143}
144
145func (c *cappedWriter) Write(p []byte) (int, error) {
146 if int64(len(p)) > c.left {
147 c.exceeded = true
148 c.stop()
149 return 0, ErrArchiveTooLarge
150 }
151 c.left -= int64(len(p))
152 return c.w.Write(p)
153}
154
155// ShowPatch returns the stat+patch text for one commit.
156func ShowPatch(dir, sha string, limit int64) (patch string, truncated bool, err error) {
157 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "show", "--stat", "--patch", "--format=", "--end-of-options", sha)
158 stdout, err := cmd.StdoutPipe()
159 if err != nil {
160 return "", false, err
161 }
162 if err := cmd.Start(); err != nil {
163 return "", false, err
164 }
165 // One byte past the limit says whether there was more.
166 data, _ := io.ReadAll(io.LimitReader(stdout, limit+1))
167 io.Copy(io.Discard, stdout)
168 if err := cmd.Wait(); err != nil {
169 return "", false, fmt.Errorf("show %s: %w", sha, err)
170 }
171 data, truncated = cutAtLine(data, limit)
172 return string(data), truncated, nil
173}
174
175// IsBinary reports whether data looks like binary content.
176func IsBinary(data []byte) bool {
177 return bytes.IndexByte(data, 0) >= 0
178}