socle

git.go

package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"time"
)

// This file is the only place socle touches git, and it does so exclusively by exec'ing
// read-only subcommands with argument vectors (never a shell).  Refs and paths pass
// through the validators in validate.go before reaching any argv.

// repo is an opened repository: a configured name bound to a resolved git directory.
// Everything socle serves is read through the git directory, so only committed state
// at refs is ever visible, never a worktree's uncommitted files.
type repo struct {
	name        string // the configured URL name
	description string // from config; may be empty
	gitDir      string // absolute path to the git directory (a worktree's .git, or the bare repo itself)
}

// gitEnv is appended to the inherited environment for every git invocation.
// Optional locks are off because a read-only server should never contend to write;
// user and system config are nulled because a server's output shouldn't vary with the
// invoking user's dotfiles (a stray log.showSignature would corrupt log parsing, say);
// LC_ALL=C keeps git's messages stable for error classification and tests.
// The repo's own config still applies, as it should: it's part of the repo.
var gitEnv = []string{
	"GIT_OPTIONAL_LOCKS=0",
	"GIT_CONFIG_GLOBAL=/dev/null",
	"GIT_CONFIG_SYSTEM=/dev/null",
	"GIT_CONFIG_NOSYSTEM=1",
	"GIT_ATTR_NOSYSTEM=1",
	"LC_ALL=C",
}

// gitCommand builds (but does not run) a git invocation with socle's standard environment.
func gitCommand(ctx context.Context, args ...string) *exec.Cmd {
	cmd := exec.CommandContext(ctx, "git", args...)
	cmd.Env = append(os.Environ(), gitEnv...)
	return cmd
}

// git runs one read-only subcommand against this repo's git directory and returns its stdout.
// Any failure comes back as a *gitError; operations where a clean nonzero exit simply means
// "no such thing" translate that via notFoundOnExit.
func (r *repo) git(args ...string) ([]byte, error) {
	argv := append([]string{"--git-dir=" + r.gitDir}, args...)
	cmd := gitCommand(context.Background(), argv...)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, newGitError(argv, err, stderr.String())
	}
	return stdout.Bytes(), nil
}

func newGitError(argv []string, err error, stderr string) *gitError {
	ge := &gitError{args: argv, exitCode: -1, stderr: strings.TrimSpace(stderr)}
	var xe *exec.ExitError
	if errors.As(err, &xe) {
		ge.exitCode = xe.ExitCode()
	} else {
		ge.cause = err
	}
	return ge
}

// openRepo binds a configured name to the git directory at its path.
// Worktree checkouts and bare repos are both accepted; resolving the git directory here
// is what makes every later read see committed state only.
// Any failure just means "not servable": openAll downgrades it to a warning and a skip.
func openRepo(name string, rc repoConfig) (*repo, error) {
	argv := []string{"-C", rc.Path, "rev-parse", "--absolute-git-dir"}
	cmd := gitCommand(context.Background(), argv...)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, newGitError(argv, err, stderr.String())
	}
	return &repo{
		name:        name,
		description: rc.Description,
		gitDir:      strings.TrimSpace(stdout.String()),
	}, nil
}

// openAll opens every configured repo, in name order.
// A path that's absent or not a git repository produces a warning and a skip, not an error:
// one config may describe repos only some hosts have.
func openAll(cfg *config) (repos map[string]*repo, warnings []string) {
	repos = make(map[string]*repo, len(cfg.Repos))
	for _, name := range sortedNames(cfg.Repos) {
		r, err := openRepo(name, cfg.Repos[name])
		if err != nil {
			warnings = append(warnings, fmt.Sprintf("repo %q: skipped: %v", name, err))
			continue
		}
		repos[name] = r
	}
	return repos, warnings
}

// matchRepo resolves a URL path (with no leading slash) to an opened repo and the remainder
// of the path after the repo name, matching configured names on segment boundaries.
// A trailing ".git" on the name in the URL is accepted as an alias for the plain name
// (people type it; git clients send it).  Returns nil if no configured name matches.
//
// The config-load rule that no repo name is a path-prefix of another is what makes this
// unambiguous: at most one configured name can match any given URL.
func matchRepo(repos map[string]*repo, urlPath string) (r *repo, rest string) {
	for i := 0; i <= len(urlPath); i++ {
		if i < len(urlPath) && urlPath[i] != '/' {
			continue
		}
		// Unconditional suffix-trim is safe: configured names never end in ".git".
		candidate := strings.TrimSuffix(urlPath[:i], ".git")
		if r := repos[candidate]; r != nil {
			return r, strings.TrimPrefix(urlPath[i:], "/")
		}
	}
	return nil, ""
}

// defaultBranch names the branch HEAD points at, or "HEAD" itself when detached.
// Total by design: "HEAD" is always a servable fallback ref, so display code can
// call this without an error path.
func (r *repo) defaultBranch() string {
	out, err := r.git("symbolic-ref", "--short", "-q", "HEAD")
	name := strings.TrimSpace(string(out))
	if err != nil || name == "" {
		return "HEAD"
	}
	return name
}

// resolveCommit turns a ref (a branch, tag, "HEAD", or hex sha) into the full sha of the
// commit it names.  This is the step that takes raw ref input; every other operation here
// expects the sha it returns.  Unresolvable refs are errNotFound; malformed ones errInvalid.
func (r *repo) resolveCommit(ref string) (string, error) {
	if err := validateRef(ref); err != nil {
		return "", err
	}
	out, err := r.git("rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
	if err != nil {
		return "", notFoundOnExit(err, fmt.Sprintf("ref %q", ref))
	}
	return strings.TrimSpace(string(out)), nil
}

// refInfo is one branch or tag: its short name and the commit it points at.
type refInfo struct {
	name string
	hash string // full sha of the commit (annotated tags are peeled to their target)
}

// refs lists the repo's branches and tags, each slice sorted by name
// (for-each-ref's default order, so the output is deterministic).
func (r *repo) refs() (branches, tags []refInfo, err error) {
	out, err := r.git("for-each-ref", "--format=%(refname)%00%(objectname)%00%(*objectname)", "refs/heads", "refs/tags")
	if err != nil {
		return nil, nil, err
	}
	for _, line := range splitLines(out) {
		full, hashes, ok := strings.Cut(line, "\x00")
		hash, peeled, ok2 := strings.Cut(hashes, "\x00")
		if !ok || !ok2 {
			return nil, nil, fmt.Errorf("unexpected for-each-ref output %q", line)
		}
		if peeled != "" {
			hash = peeled // an annotated tag: report the commit, not the tag object
		}
		switch {
		case strings.HasPrefix(full, "refs/heads/"):
			branches = append(branches, refInfo{strings.TrimPrefix(full, "refs/heads/"), hash})
		case strings.HasPrefix(full, "refs/tags/"):
			tags = append(tags, refInfo{strings.TrimPrefix(full, "refs/tags/"), hash})
		}
	}
	return branches, tags, nil
}

// entryType reports what lives at a path within a commit: "tree" for directories
// (including path "" for the root), "blob" for files, "commit" for submodule pointers.
// sha is a full commit sha as returned by resolveCommit.
func (r *repo) entryType(sha, path string) (string, error) {
	if err := validateTreePath(path); err != nil {
		return "", err
	}
	out, err := r.git("cat-file", "-t", sha+":"+path)
	if err != nil {
		return "", notFoundOnExit(err, fmt.Sprintf("path %q at %s", path, sha))
	}
	return strings.TrimSpace(string(out)), nil
}

// treeEntry is one row of a directory listing.
type treeEntry struct {
	mode string // git's octal mode string: "100644", "100755", "040000", "120000", "160000"
	typ  string // "blob", "tree", or "commit" (submodule)
	hash string
	size int64  // blob size in bytes; -1 for non-blobs
	name string // the entry's own name, no path prefix
}

// lsTree lists the directory at a path within a commit (path "" for the root),
// in git's canonical tree order.  sha is a full commit sha as returned by resolveCommit.
// Asking for a path that is a file rather than a directory is errNotFound;
// callers that don't know which they have ask entryType first.
func (r *repo) lsTree(sha, path string) ([]treeEntry, error) {
	if err := validateTreePath(path); err != nil {
		return nil, err
	}
	out, err := r.git("ls-tree", "-z", "-l", sha+":"+path)
	if err != nil {
		return nil, notFoundOnExit(err, fmt.Sprintf("directory %q at %s", path, sha))
	}
	var entries []treeEntry
	for _, rec := range strings.Split(string(out), "\x00") {
		if rec == "" {
			continue // the trailing NUL
		}
		// Each record is "<mode> <type> <hash> <size>\t<name>"; size is space-padded.
		head, name, ok := strings.Cut(rec, "\t")
		fields := strings.Fields(head)
		if !ok || len(fields) != 4 {
			return nil, fmt.Errorf("unexpected ls-tree record %q", rec)
		}
		size := int64(-1)
		if fields[3] != "-" {
			size, err = strconv.ParseInt(fields[3], 10, 64)
			if err != nil {
				return nil, fmt.Errorf("unexpected ls-tree size in record %q", rec)
			}
		}
		entries = append(entries, treeEntry{mode: fields[0], typ: fields[1], hash: fields[2], size: size, name: name})
	}
	return entries, nil
}

// blobSize reports the size in bytes of the blob at a path within a commit.
// sha is a full commit sha as returned by resolveCommit.
func (r *repo) blobSize(sha, path string) (int64, error) {
	if err := validateTreePath(path); err != nil {
		return 0, err
	}
	out, err := r.git("cat-file", "-s", sha+":"+path)
	if err != nil {
		return 0, notFoundOnExit(err, fmt.Sprintf("blob %q at %s", path, sha))
	}
	return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
}

// catBlob streams the raw bytes of the blob at a path within a commit to w.
// sha is a full commit sha as returned by resolveCommit.
// The missing-blob case fails before any byte is written; a failure after streaming has
// begun can leave a partial write behind, which an HTTP caller surfaces naturally as a
// truncated response against the Content-Length from blobSize.
func (r *repo) catBlob(sha, path string, w io.Writer) error {
	if err := validateTreePath(path); err != nil {
		return err
	}
	argv := []string{"--git-dir=" + r.gitDir, "cat-file", "blob", sha + ":" + path}
	cmd := gitCommand(context.Background(), argv...)
	cmd.Stdout = w
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return notFoundOnExit(newGitError(argv, err, stderr.String()), fmt.Sprintf("blob %q at %s", path, sha))
	}
	return nil
}

// commitInfo is one entry of a commit log.
type commitInfo struct {
	hash    string
	author  string
	time    time.Time // author time, in UTC
	subject string
}

// commits returns the log reachable from a ref, newest first, capped at limit.
func (r *repo) commits(ref string, limit int) ([]commitInfo, error) {
	if err := validateRef(ref); err != nil {
		return nil, err
	}
	out, err := r.git("log", "-n", strconv.Itoa(limit), "--format=%H%x00%an%x00%at%x00%s", "--end-of-options", ref, "--")
	if err != nil {
		return nil, notFoundOnExit(err, fmt.Sprintf("ref %q", ref))
	}
	var cs []commitInfo
	for _, line := range splitLines(out) {
		parts := strings.Split(line, "\x00")
		if len(parts) != 4 {
			return nil, fmt.Errorf("unexpected log output %q", line)
		}
		unix, err := strconv.ParseInt(parts[2], 10, 64)
		if err != nil {
			return nil, fmt.Errorf("unexpected author time in log output %q", line)
		}
		cs = append(cs, commitInfo{hash: parts[0], author: parts[1], time: time.Unix(unix, 0).UTC(), subject: parts[3]})
	}
	return cs, nil
}

// commitDetail is the full description of one commit, as the commit page wants it.
type commitDetail struct {
	hash    string
	author  string
	time    time.Time
	parents []string // full shas; empty for a root commit
	message string   // the full commit message, subject line included
}

// commit reads one commit's metadata and full message.  sha must be a full commit sha
// (resolveCommit's output), like every other operation here.
func (r *repo) commit(sha string) (commitDetail, error) {
	out, err := r.git("show", "-s", "--format=%H%x00%an%x00%at%x00%P%x00%B", "--end-of-options", sha, "--")
	if err != nil {
		return commitDetail{}, notFoundOnExit(err, fmt.Sprintf("commit %q", sha))
	}
	parts := strings.SplitN(string(out), "\x00", 5)
	if len(parts) != 5 {
		return commitDetail{}, fmt.Errorf("unexpected show output for %q", sha)
	}
	unix, err := strconv.ParseInt(parts[2], 10, 64)
	if err != nil {
		return commitDetail{}, fmt.Errorf("unexpected author time in show output %q", parts[2])
	}
	c := commitDetail{
		hash:    parts[0],
		author:  parts[1],
		time:    time.Unix(unix, 0).UTC(),
		message: strings.TrimRight(parts[4], "\n"),
	}
	if parts[3] != "" {
		c.parents = strings.Split(parts[3], " ")
	}
	return c, nil
}

// diff returns the commit's patch: against its first parent, or from scratch for a root
// commit.  (For a merge commit the first parent is the branch merged *into*, which is the
// diff a reader almost always means.)  Rename detection is on, matching what humans expect
// to read; the output is git's ordinary unified diff, uncolored.
func (r *repo) diff(c commitDetail) ([]byte, error) {
	var out []byte
	var err error
	if len(c.parents) == 0 {
		out, err = r.git("diff-tree", "--root", "-p", "-M", "--no-commit-id", "--end-of-options", c.hash, "--")
	} else {
		out, err = r.git("diff-tree", "-p", "-M", "--no-commit-id", "--end-of-options", c.parents[0], c.hash, "--")
	}
	if err != nil {
		return nil, notFoundOnExit(err, fmt.Sprintf("commit %q", c.hash))
	}
	return out, nil
}

// uploadPackAdvertise writes the ref advertisement for fetch, as produced by
// git upload-pack --advertise-refs.  The smart-HTTP "# service=" pkt-line preamble is the
// HTTP layer's framing concern and is not written here.
// protocol carries the client's Git-Protocol header value verbatim (empty is fine);
// passing it through as GIT_PROTOCOL is what lets protocol v2 negotiate.
func (r *repo) uploadPackAdvertise(ctx context.Context, w io.Writer, protocol string) error {
	return r.uploadPack(ctx, nil, w, protocol, true)
}

// uploadPackRPC runs one stateless upload-pack exchange: the request body in, the response out.
// This is the whole fetch/clone data path, and it is read-only by nature:
// upload-pack only ever sends objects, and no receive-pack exists anywhere in socle.
// ctx should be the HTTP request's context: cancellation is what kills the subprocess
// when a client vanishes mid-transfer.
func (r *repo) uploadPackRPC(ctx context.Context, body io.Reader, w io.Writer, protocol string) error {
	return r.uploadPack(ctx, body, w, protocol, false)
}

func (r *repo) uploadPack(ctx context.Context, body io.Reader, w io.Writer, protocol string, advertise bool) error {
	argv := []string{"upload-pack", "--stateless-rpc"}
	if advertise {
		argv = append(argv, "--advertise-refs")
	}
	argv = append(argv, r.gitDir)
	cmd := gitCommand(ctx, argv...)
	if protocol != "" {
		cmd.Env = append(cmd.Env, "GIT_PROTOCOL="+protocol)
	}
	cmd.Stdin = body
	cmd.Stdout = w
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return newGitError(argv, err, stderr.String())
	}
	return nil
}

// splitLines splits command output on newlines, dropping the trailing empty piece.
func splitLines(out []byte) []string {
	s := strings.TrimSuffix(string(out), "\n")
	if s == "" {
		return nil
	}
	return strings.Split(s, "\n")
}

git.go · 15.2 KiB · 68bc19f54a