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 · aa62edebd4