internal/gitutil/read.go

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

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}