gitmote

gitmote / internal / repo / browse.go  ·  history  ·  branches

11696 bytes · raw

package repo

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

// ErrNotFound reports that a browse lookup — a ref, a tree, or a blob — did not
// resolve in the materialized repo. Handlers map it to 404. A git subcommand
// that exits non-zero on these read-only plumbing calls means "no such
// object/path", so it is reported as ErrNotFound; a failure to launch git at
// all surfaces as a distinct error.
var ErrNotFound = errors.New("repo: not found")

// TreeEntry is one row of a directory listing (git ls-tree --long).
type TreeEntry struct {
	Mode string // e.g. "100644"
	Type string // "blob", "tree", or "commit" (submodule)
	SHA  string
	Size int64  // 0 for non-blob entries (git prints "-")
	Path string // full path from the repo root, for links
	Name string // base name, for display
}

// Commit is a single entry of history: the fields the browse UI shows.
type Commit struct {
	SHA     string
	Author  string
	Email   string
	When    time.Time
	Subject string
	Body    string
}

// commitFormat is a git pretty-format whose fields are unit-separated (\x1f) and
// whose records are terminated by \x1e, so a multi-line body never confuses
// parsing. Fields, in order: hash, author name, author email, author date
// (strict ISO 8601), subject, body.
const commitFormat = "%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b%x1e"

// ResolveRef resolves ref (a branch, tag, or hash) to a commit SHA in the
// materialized repo at dir. It rejects a ref beginning with "-" (flag
// injection) and returns ErrNotFound for an unknown ref.
func ResolveRef(ctx context.Context, dir, ref string) (string, error) {
	if err := safeRev(ref); err != nil {
		return "", err
	}
	out, err := runGitOut(ctx, dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}

// Tree lists the immediate children of path within the commit sha. An empty
// path lists the repo root. Entries carry mode, type, sha, size, and name.
func Tree(ctx context.Context, dir, sha, treePath string) ([]TreeEntry, error) {
	if err := safeRev(sha); err != nil {
		return nil, err
	}
	if err := safePath(treePath); err != nil {
		return nil, err
	}
	args := []string{"ls-tree", "--long", "--end-of-options", sha}
	if treePath != "" {
		// A trailing slash makes ls-tree list the directory's contents rather
		// than the directory entry itself. A lone "--" with no pathspec makes
		// ls-tree match nothing, so only add the separator when scoping.
		args = append(args, "--", strings.TrimSuffix(treePath, "/")+"/")
	}
	out, err := runGitOut(ctx, dir, args...)
	if err != nil {
		return nil, err
	}
	return parseTree(out), nil
}

// EntryType reports whether p names a "tree" or a "blob" within commit sha,
// returning ErrNotFound when it names neither. An empty path is the repo root, a
// tree. It lets the unified browse handler choose the listing vs the file view,
// and the blob handler redirect a directory to its tree URL.
func EntryType(ctx context.Context, dir, sha, p string) (string, error) {
	if p == "" {
		return "tree", nil
	}
	if err := safeRev(sha); err != nil {
		return "", err
	}
	if err := safePath(p); err != nil {
		return "", err
	}
	// Without a trailing slash, ls-tree reports the entry itself (one row), so its
	// type field distinguishes a directory from a file.
	out, err := runGitOut(ctx, dir, "ls-tree", "--long", "--end-of-options", sha, "--", strings.TrimSuffix(p, "/"))
	if err != nil {
		return "", err
	}
	entries := parseTree(out)
	if len(entries) == 0 {
		return "", ErrNotFound
	}
	return entries[0].Type, nil
}

// parseTree parses `git ls-tree --long` output: "<mode> <type> <sha> <size>\t<name>".
func parseTree(out []byte) []TreeEntry {
	var entries []TreeEntry
	for _, line := range strings.Split(string(out), "\n") {
		if line == "" {
			continue
		}
		meta, name, ok := strings.Cut(line, "\t")
		if !ok {
			continue
		}
		fields := strings.Fields(meta)
		if len(fields) < 4 {
			continue
		}
		var size int64
		if n, err := strconv.ParseInt(fields[3], 10, 64); err == nil {
			size = n
		}
		entries = append(entries, TreeEntry{
			Mode: fields[0],
			Type: fields[1],
			SHA:  fields[2],
			Size: size,
			Path: name,
			Name: path.Base(name),
		})
	}
	return entries
}

// Blob reads the file at path within commit sha, returning its content, size,
// and whether it looks binary (a NUL byte in the leading bytes). It returns
// ErrNotFound when the path names no blob.
func Blob(ctx context.Context, dir, sha, blobPath string) (content []byte, size int64, binary bool, err error) {
	if err := safeRev(sha); err != nil {
		return nil, 0, false, err
	}
	if err := safePath(blobPath); err != nil {
		return nil, 0, false, err
	}
	out, err := runGitOut(ctx, dir, "cat-file", "blob", "--end-of-options", sha+":"+blobPath)
	if err != nil {
		return nil, 0, false, err
	}
	return out, int64(len(out)), isBinary(out), nil
}

// BlobSize returns the byte size of the blob at path within commit sha without
// reading its content, and ErrNotFound when the path names no blob. The raw
// endpoint uses it to confirm existence before streaming.
func BlobSize(ctx context.Context, dir, sha, blobPath string) (int64, error) {
	if err := safeRev(sha); err != nil {
		return 0, err
	}
	if err := safePath(blobPath); err != nil {
		return 0, err
	}
	out, err := runGitOut(ctx, dir, "cat-file", "-s", "--end-of-options", sha+":"+blobPath)
	if err != nil {
		return 0, err
	}
	return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
}

// BlobReader streams the blob at path within commit sha. The caller must Close
// the reader, which also reaps the git process. Existence should be confirmed
// with BlobSize first, so a missing path is a 404 before any bytes are sent.
func BlobReader(ctx context.Context, dir, sha, blobPath string) (io.ReadCloser, error) {
	if err := safeRev(sha); err != nil {
		return nil, err
	}
	if err := safePath(blobPath); err != nil {
		return nil, err
	}
	cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", "--end-of-options", sha+":"+blobPath)
	cmd.Dir = dir
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	if err := cmd.Start(); err != nil {
		return nil, err
	}
	return &gitReadCloser{stdout: stdout, cmd: cmd}, nil
}

// gitReadCloser wraps a running git process's stdout so Close both closes the
// pipe and waits for the process to exit.
type gitReadCloser struct {
	stdout io.ReadCloser
	cmd    *exec.Cmd
}

func (g *gitReadCloser) Read(p []byte) (int, error) { return g.stdout.Read(p) }

func (g *gitReadCloser) Close() error {
	_ = g.stdout.Close()
	return g.cmd.Wait()
}

// Log returns the commit history reachable from sha, optionally scoped to a
// path, newest first. It fetches limit+1 commits: if more than limit come back
// the extra is dropped and more is true, so the caller can show a truncation
// marker instead of silently capping.
func Log(ctx context.Context, dir, sha, logPath string, limit int) (commits []Commit, more bool, err error) {
	if err := safeRev(sha); err != nil {
		return nil, false, err
	}
	if err := safePath(logPath); err != nil {
		return nil, false, err
	}
	args := []string{"log", "--format=" + commitFormat, "-n", strconv.Itoa(limit + 1), "--end-of-options", sha}
	if logPath != "" {
		args = append(args, "--", logPath)
	}
	out, err := runGitOut(ctx, dir, args...)
	if err != nil {
		return nil, false, err
	}
	commits = parseCommits(out)
	if len(commits) > limit {
		return commits[:limit], true, nil
	}
	return commits, false, nil
}

// Show returns a single commit's metadata and its unified diff. --root makes the
// initial commit produce a diff against the empty tree rather than nothing.
func Show(ctx context.Context, dir, sha string) (Commit, string, error) {
	if err := safeRev(sha); err != nil {
		return Commit{}, "", err
	}
	out, err := runGitOut(ctx, dir, "show", "--format="+commitFormat, "--patch", "--root", "--end-of-options", sha)
	if err != nil {
		return Commit{}, "", err
	}
	// The format ends with \x1e; the patch follows after it.
	head, diff, _ := bytes.Cut(out, []byte{0x1e})
	commits := parseCommits(append(head, 0x1e))
	if len(commits) == 0 {
		return Commit{}, "", ErrNotFound
	}
	return commits[0], strings.TrimLeft(string(diff), "\n"), nil
}

// Compare returns the commits head adds over base and the unified diff between
// them. The commit list is the two-dot range base..head (commits reachable from
// head but not base); the diff is the three-dot base...head (head against the
// merge base), matching a GitHub compare view. base and head are commit SHAs
// (or any rev safeRev accepts); an unknown rev surfaces as ErrNotFound.
func Compare(ctx context.Context, dir, base, head string) (commits []Commit, diff string, err error) {
	if err := safeRev(base); err != nil {
		return nil, "", err
	}
	if err := safeRev(head); err != nil {
		return nil, "", err
	}
	log, err := runGitOut(ctx, dir, "log", "--format="+commitFormat, "--end-of-options", base+".."+head)
	if err != nil {
		return nil, "", err
	}
	patch, err := runGitOut(ctx, dir, "diff", "--end-of-options", base+"..."+head)
	if err != nil {
		return nil, "", err
	}
	return parseCommits(log), strings.TrimLeft(string(patch), "\n"), nil
}

// parseCommits splits git output framed by commitFormat into commits.
func parseCommits(out []byte) []Commit {
	var commits []Commit
	for _, rec := range strings.Split(string(out), "\x1e") {
		rec = strings.Trim(rec, "\n")
		if rec == "" {
			continue
		}
		fields := strings.Split(rec, "\x1f")
		if len(fields) < 6 {
			continue
		}
		when, _ := time.Parse(time.RFC3339, fields[3])
		commits = append(commits, Commit{
			SHA:     fields[0],
			Author:  fields[1],
			Email:   fields[2],
			When:    when,
			Subject: fields[4],
			Body:    strings.TrimRight(fields[5], "\n"),
		})
	}
	return commits
}

// isBinary flags content as binary if a NUL byte appears in the leading bytes,
// git's own heuristic for a non-text blob.
func isBinary(content []byte) bool {
	const sniff = 8000
	if len(content) > sniff {
		content = content[:sniff]
	}
	return bytes.IndexByte(content, 0) >= 0
}

// safeRev rejects a rev (ref or sha) that could be read as a git flag.
func safeRev(rev string) error {
	if rev == "" || strings.HasPrefix(rev, "-") {
		return fmt.Errorf("repo: unsafe rev %q: %w", rev, ErrNotFound)
	}
	return nil
}

// safePath rejects a pathspec that is absolute or escapes the repo root, so a
// browse request can never reach outside the materialized tree.
func safePath(p string) error {
	if p == "" {
		return nil
	}
	if strings.HasPrefix(p, "/") {
		return fmt.Errorf("repo: unsafe path %q: %w", p, ErrNotFound)
	}
	for _, seg := range strings.Split(p, "/") {
		if seg == ".." {
			return fmt.Errorf("repo: unsafe path %q: %w", p, ErrNotFound)
		}
	}
	return nil
}

// runGitOut runs a git subcommand in dir and returns its stdout. A non-zero
// exit — the "no such object/path/ref" case for these read-only plumbing calls
// — is reported as ErrNotFound with git's stderr attached; a failure to launch
// git surfaces as a plain error.
func runGitOut(ctx context.Context, dir string, args ...string) ([]byte, error) {
	cmd := exec.CommandContext(ctx, "git", args...)
	cmd.Dir = dir
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		var ee *exec.ExitError
		if errors.As(err, &ee) {
			return nil, fmt.Errorf("git %s: %s: %w",
				strings.Join(args, " "), strings.TrimSpace(stderr.String()), ErrNotFound)
		}
		return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
	}
	return stdout.Bytes(), nil
}