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