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