internal/gitutil/read.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/gitutil/read.go history · blame · raw

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}