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
}
|