Download
524 lines 15 KiBGoModified 2026-10-21 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
// Package gitx wraps the git command line for bare repositories. No cgo, no
// libgit2: every operation is a plumbing command with machine-readable output.
package gitx

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"
)

// Repo is a bare repository on disk.
type Repo struct {
	Dir string
}

// env makes git tolerate a repository owned by a different user (in
// development, or a repository another account made) and keeps the
// operator's global config out of the picture.
func env() []string {
	return append(os.Environ(),
		"GIT_CONFIG_NOSYSTEM=1",
		"GIT_CONFIG_COUNT=1",
		"GIT_CONFIG_KEY_0=safe.directory",
		"GIT_CONFIG_VALUE_0=*",
		"GIT_TERMINAL_PROMPT=0",
		"LC_ALL=C",
	)
}

// gitPath finds git on PATH, falling back to where OpenBSD's package
// installs it: cron's PATH is /bin:/usr/bin, which misses the package, and
// a `repo-render render --all` from cron must still work.
var gitPath = sync.OnceValue(func() string {
	if p, err := exec.LookPath("git"); err == nil {
		return p
	}
	return "/usr/local/bin/git"
})

// Command builds an exec.Cmd for git running against the repo.
func (r Repo) Command(ctx context.Context, args ...string) *exec.Cmd {
	cmd := exec.CommandContext(ctx, gitPath(), append([]string{"-C", r.Dir}, args...)...)
	cmd.Env = env()
	return cmd
}

// Run executes git and returns stdout. Stderr is folded into the error.
func (r Repo) Run(args ...string) ([]byte, error) {
	cmd := r.Command(context.Background(), args...)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	// Read stdout here, not through cmd.Output: that copies it in another
	// goroutine while this one blocks waiting for git to exit, and with a
	// single CPU, output bigger than a pipe holds then stalls git until the
	// runtime takes the CPU back from the wait, up to 10 ms later.
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	var out []byte
	if err = cmd.Start(); err == nil {
		out, err = io.ReadAll(stdout)
		if werr := cmd.Wait(); werr != nil {
			err = werr
		}
	}
	if err != nil {
		return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
	}
	return out, nil
}

// Exists reports whether Dir is a git repository.
func (r Repo) Exists() bool {
	if st, err := os.Stat(r.Dir); err != nil || !st.IsDir() {
		return false
	}
	_, err := r.Run("rev-parse", "--git-dir")
	return err == nil
}

// RevParse resolves a ref to a full SHA. Returns "" (no error) when the ref
// does not exist.
func (r Repo) RevParse(ref string) (string, error) {
	out, err := r.Run("rev-parse", "--verify", "--quiet", ref+"^{commit}")
	if err != nil {
		var ee *exec.ExitError
		if errors.As(err, &ee) && ee.ExitCode() == 1 {
			return "", nil
		}
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}

// HeadBranch returns the branch HEAD points at ("main" for refs/heads/main).
func (r Repo) HeadBranch() (string, error) {
	out, err := r.Run("symbolic-ref", "--quiet", "HEAD")
	if err != nil {
		return "", err
	}
	return strings.TrimPrefix(strings.TrimSpace(string(out)), "refs/heads/"), nil
}

var shaRE = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`)

// IsSHA reports whether s is a full lowercase hex object id.
func IsSHA(s string) bool { return shaRE.MatchString(s) }

// IsZeroSHA reports whether s is the all-zero object id, SHA-1 or SHA-256,
// that git uses for "no object" in hooks.
func IsZeroSHA(s string) bool { return IsSHA(s) && strings.Trim(s, "0") == "" }

// IsAncestor reports whether a is an ancestor of (or equal to) b.
func (r Repo) IsAncestor(a, b string) (bool, error) {
	cmd := r.Command(context.Background(), "merge-base", "--is-ancestor", a, b)
	err := cmd.Run()
	if err == nil {
		return true, nil
	}
	var ee *exec.ExitError
	if errors.As(err, &ee) && ee.ExitCode() == 1 {
		return false, nil
	}
	return false, fmt.Errorf("git merge-base: %w", err)
}

// Entry is one row of a recursive tree listing.
type Entry struct {
	Mode string // "100644", "100755", "120000", "160000"
	SHA  string
	Size int64 // -1 for non-blobs
	Path string
}

// IsSymlink reports whether the entry is a symbolic link.
func (e Entry) IsSymlink() bool { return e.Mode == "120000" }

// IsSubmodule reports whether the entry is a gitlink.
func (e Entry) IsSubmodule() bool { return e.Mode == "160000" }

// IsExecutable reports whether the entry is an executable blob.
func (e Entry) IsExecutable() bool { return e.Mode == "100755" }

// LsTree lists every entry under commit recursively (blobs, symlinks and
// submodules; trees are not listed). Paths are repo-relative with '/'.
func (r Repo) LsTree(commit string) ([]Entry, error) {
	out, err := r.Run("ls-tree", "-r", "-l", "-z", commit)
	if err != nil {
		return nil, err
	}
	var entries []Entry
	for rec := range bytes.SplitSeq(out, []byte{0}) {
		if len(rec) == 0 {
			continue
		}
		// <mode> SP <type> SP <sha> SP* <size|-> TAB <path>
		meta, path, ok := bytes.Cut(rec, []byte{'\t'})
		fields := strings.Fields(string(meta))
		if !ok || len(fields) != 4 {
			return nil, fmt.Errorf("ls-tree: malformed record %q", rec)
		}
		e := Entry{Mode: fields[0], SHA: fields[2], Size: -1, Path: string(path)}
		if fields[3] != "-" {
			n, err := strconv.ParseInt(fields[3], 10, 64)
			if err != nil {
				return nil, fmt.Errorf("ls-tree: bad size in %q", rec)
			}
			e.Size = n
		}
		entries = append(entries, e)
	}
	return entries, nil
}

// Change is one row of a diff between two commits.
type Change struct {
	Status byte // 'A', 'M', 'D'
	Path   string
}

// DiffTree lists path-level changes from old to new, without rename
// detection (a rename is a D plus an A). Type changes count as M.
func (r Repo) DiffTree(oldc, newc string) ([]Change, error) {
	out, err := r.Run("diff-tree", "-r", "-z", "--no-renames", "--name-status", oldc, newc)
	if err != nil {
		return nil, err
	}
	parts := bytes.Split(out, []byte{0})
	var changes []Change
	for i := 0; i+1 < len(parts); i += 2 {
		st := parts[i]
		if len(st) == 0 {
			break
		}
		c := Change{Status: st[0], Path: string(parts[i+1])}
		switch c.Status {
		case 'A', 'M', 'D':
		case 'T':
			c.Status = 'M'
		default:
			return nil, fmt.Errorf("diff-tree: unexpected status %q for %s", st, c.Path)
		}
		changes = append(changes, c)
	}
	return changes, nil
}

// Commit returns the committer time and subject line of sha.
func (r Repo) Commit(sha string) (when time.Time, subject string, err error) {
	out, err := r.Run("log", "-1", "--format=%ct %s", sha)
	if err != nil {
		return time.Time{}, "", err
	}
	ct, subject, _ := strings.Cut(strings.TrimRight(string(out), "\n"), " ")
	ts, err := strconv.ParseInt(ct, 10, 64)
	if err != nil {
		return time.Time{}, "", fmt.Errorf("git log: malformed output %q", out)
	}
	return time.Unix(ts, 0).UTC(), subject, nil
}

// Description reads the bare repo's description file (gitweb convention).
// The stock placeholder text counts as empty.
func (r Repo) Description() string {
	b, err := os.ReadFile(filepath.Join(r.Dir, "description"))
	if err != nil {
		return ""
	}
	s := strings.TrimSpace(string(b))
	if strings.HasPrefix(s, "Unnamed repository") {
		return ""
	}
	return s
}

// Tag is a tag ref: Object is what the ref points at (a tag object for
// annotated tags), SHA is the peeled commit.
type Tag struct {
	Name   string
	SHA    string
	Object string
}

// Tags lists all tags, newest first by tagger/committer date.
func (r Repo) Tags() ([]Tag, error) {
	out, err := r.Run("for-each-ref", "--sort=-creatordate", "--format=%(refname:strip=2)%00%(*objectname)%00%(objectname)", "refs/tags")
	if err != nil {
		return nil, err
	}
	var tags []Tag
	for line := range strings.Lines(string(out)) {
		f := strings.Split(strings.TrimSuffix(line, "\n"), "\x00")
		if len(f) != 3 {
			continue
		}
		sha := f[1]
		if sha == "" {
			sha = f[2]
		}
		tags = append(tags, Tag{Name: f[0], SHA: sha, Object: f[2]})
	}
	return tags, nil
}

// Archive writes a gzipped tarball of commit to w with every path
// prefixed, compressed at level (1-9, or 0 for git's default).
func (r Repo) Archive(w io.Writer, commit, prefix string, level int) error {
	args := []string{"archive", "--format=tar.gz", "--prefix=" + prefix}
	if level > 0 {
		args = append(args, "-"+strconv.Itoa(level))
	}
	cmd := r.Command(context.Background(), append(args, commit)...)
	cmd.Stdout = w
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("git archive: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	return nil
}

// LastModified walks history from commit backwards and calls found(path,
// unixTime) once for each wanted path, with the time of the newest commit
// that touched it. It stops as soon as every wanted path has been seen, so
// a repository whose files all changed recently costs a handful of commits.
// Paths never seen (possible with grafted or shallow history) are left out.
func (r Repo) LastModified(commit string, wanted map[string]bool, found func(string, int64)) error {
	if len(wanted) == 0 {
		return nil
	}
	cmd := r.Command(context.Background(), "log", "--format=%x01%ct", "--name-only", "--no-renames", "-z", commit, "--")
	out, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Start(); err != nil {
		return err
	}
	left := len(wanted)
	seen := make(map[string]bool, len(wanted))
	var when int64
	sc := bufio.NewScanner(out)
	sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
	sc.Split(func(data []byte, atEOF bool) (int, []byte, error) {
		if i := bytes.IndexByte(data, 0); i >= 0 {
			return i + 1, data[:i], nil
		}
		if atEOF && len(data) > 0 {
			return len(data), data, nil
		}
		return 0, nil, nil
	})
	for left > 0 && sc.Scan() {
		tok := bytes.TrimLeft(sc.Bytes(), "\n")
		if len(tok) == 0 {
			continue
		}
		if tok[0] == 1 {
			when, _ = strconv.ParseInt(string(tok[1:]), 10, 64)
			continue
		}
		p := string(tok)
		if wanted[p] && !seen[p] {
			seen[p] = true
			left--
			found(p, when)
		}
	}
	if left == 0 {
		// Done early: stop git rather than drain it. Kill it here, not
		// through cancel, whose kill runs in another goroutine: with a
		// single CPU that waits until the runtime takes the CPU back from
		// Wait, up to 10 ms of git walking history for nothing.
		_ = cmd.Process.Kill()
		_ = cmd.Wait()
		return nil
	}
	if err := sc.Err(); err != nil {
		_ = cmd.Wait()
		return err
	}
	if err := cmd.Wait(); err != nil {
		return fmt.Errorf("git log: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	return nil
}

// Batch is a long-lived `git cat-file --batch` process. Get is safe for
// concurrent use; requests are serialized internally.
type Batch struct {
	mu   sync.Mutex
	cmd  *exec.Cmd
	in   io.WriteCloser
	out  *bufio.Reader
	done bool
}

// NewBatch starts the cat-file process.
func (r Repo) NewBatch() (*Batch, error) {
	cmd := r.Command(context.Background(), "cat-file", "--batch")
	in, err := cmd.StdinPipe()
	if err != nil {
		return nil, err
	}
	out, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	cmd.Stderr = os.Stderr
	if err := cmd.Start(); err != nil {
		return nil, err
	}
	return &Batch{cmd: cmd, in: in, out: bufio.NewReaderSize(out, 1<<20)}, nil
}

// header asks for a blob and reads cat-file's header for it, leaving its
// content next on b.out. b.mu must be held.
func (b *Batch) header(sha string) (int64, error) {
	if b.done {
		return 0, errors.New("cat-file batch closed")
	}
	if _, err := io.WriteString(b.in, sha+"\n"); err != nil {
		return 0, err
	}
	header, err := b.out.ReadString('\n')
	if err != nil {
		return 0, fmt.Errorf("cat-file: %w", err)
	}
	f := strings.Fields(header)
	if len(f) == 2 && f[1] == "missing" {
		return 0, fmt.Errorf("%s: object missing", sha)
	}
	if len(f) != 3 {
		return 0, fmt.Errorf("cat-file: malformed header %q", header)
	}
	size, err := strconv.ParseInt(f[2], 10, 64)
	if err != nil {
		return 0, fmt.Errorf("cat-file: bad size in %q", header)
	}
	return size, nil
}

// Get returns the full content of a blob.
func (b *Batch) Get(sha string) ([]byte, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	size, err := b.header(sha)
	if err != nil {
		return nil, err
	}
	buf := make([]byte, size+1) // trailing newline
	if _, err := io.ReadFull(b.out, buf); err != nil {
		return nil, fmt.Errorf("cat-file: %w", err)
	}
	return buf[:size], nil
}

// Copy streams a blob's content to w, never holding all of it.
func (b *Batch) Copy(sha string, w io.Writer) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	size, err := b.header(sha)
	if err != nil {
		return err
	}
	if _, err := io.CopyN(w, b.out, size); err != nil {
		return fmt.Errorf("cat-file: %w", err)
	}
	if _, err := b.out.Discard(1); err != nil { // trailing newline
		return fmt.Errorf("cat-file: %w", err)
	}
	return nil
}

// Close stops the process.
func (b *Batch) Close() error {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.done {
		return nil
	}
	b.done = true
	_ = b.in.Close()
	return b.cmd.Wait()
}

// PackObjects writes one packfile holding everything reachable from refs
// to prefix-<hash>.pack/.idx and returns the hash. A ref written "^<sha>"
// excludes what that object reaches, as in git rev-list. Deltas already present
// in the repository's own packs are reused, so this is mostly I/O.
func (r Repo) PackObjects(prefix string, refs []string) (string, error) {
	cmd := r.Command(context.Background(), "pack-objects", "-q", "--revs", "--delta-base-offset", prefix)
	cmd.Stdin = strings.NewReader(strings.Join(refs, "\n") + "\n")
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("git pack-objects: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	hash := strings.TrimSpace(string(out))
	if !IsSHA(hash) {
		return "", fmt.Errorf("git pack-objects: unexpected output %q", hash)
	}
	return hash, nil
}

// SSH is how git reaches an ssh remote. Host keys come from ssh's own
// files (/etc/ssh/ssh_known_hosts).
type SSH struct {
	Key string // private key file (ssh -i); optional
}

// shellQuote single-quotes s for sh.
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }

// command is the GIT_SSH_COMMAND value. ssh never prompts (no terminal, no
// unknown host keys); git runs the value through a shell, so the key path
// is single-quoted.
func (s SSH) command() string {
	c := "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes"
	if s.Key != "" {
		c += " -i " + shellQuote(s.Key)
	}
	return c
}

func (s SSH) apply(cmd *exec.Cmd) {
	cmd.Env = append(cmd.Env, "GIT_SSH_COMMAND="+s.command())
}

// Push mirrors the branch and all tags to a remote with explicit refspecs.
// Never --mirror: that would delete remote-only branches.
func (r Repo) Push(ctx context.Context, remote, branch string, ssh SSH, logw io.Writer) error {
	cmd := r.Command(ctx, "push", "--no-verify", remote,
		"+refs/heads/"+branch+":refs/heads/"+branch,
		"refs/tags/*:refs/tags/*")
	ssh.apply(cmd)
	cmd.Stdout = logw
	cmd.Stderr = logw
	return cmd.Run()
}

// Version returns the git version string, for `check`.
func Version() (string, error) {
	out, err := exec.Command(gitPath(), "--version").Output()
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}