internal/gitutil/read.go

332a13feaa444362bb4cc872ca95c62ccafcb64c
gitbay/internal/gitutil/read.go history · blame · raw

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}