Gitfed
bastien-mrq/gitfed / internal / gitexec / gitexec.go
// Package gitexec wraps git-upload-pack / git-receive-pack as subprocesses,
// per DESIGN.md §8 ("pas de réécriture du protocole git : on wrappe
// git-receive-pack / git-upload-pack").
package gitexec

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
)

// Verb identifies which git service was requested.
type Verb string

const (
	UploadPack  Verb = "git-upload-pack"
	ReceivePack Verb = "git-receive-pack"
)

// ParseCommand parses an SSH exec command line such as
// `git-upload-pack 'alice/repo.git'` or `git upload-pack 'alice/repo.git'`
// (both forms are accepted by real git clients) into a verb and repo name.
func ParseCommand(cmd string) (Verb, string, error) {
	cmd = strings.TrimSpace(cmd)

	var verb Verb
	var rest string
	switch {
	case strings.HasPrefix(cmd, "git-upload-pack "):
		verb, rest = UploadPack, strings.TrimPrefix(cmd, "git-upload-pack ")
	case strings.HasPrefix(cmd, "git-receive-pack "):
		verb, rest = ReceivePack, strings.TrimPrefix(cmd, "git-receive-pack ")
	case strings.HasPrefix(cmd, "git upload-pack "):
		verb, rest = UploadPack, strings.TrimPrefix(cmd, "git upload-pack ")
	case strings.HasPrefix(cmd, "git receive-pack "):
		verb, rest = ReceivePack, strings.TrimPrefix(cmd, "git receive-pack ")
	default:
		return "", "", fmt.Errorf("gitexec: unsupported command %q", cmd)
	}

	repo := strings.Trim(strings.TrimSpace(rest), "'\"")
	repo = strings.TrimPrefix(repo, "/")
	repo = strings.TrimSuffix(repo, ".git")
	if repo == "" {
		return "", "", fmt.Errorf("gitexec: empty repo name in command %q", cmd)
	}
	if !validRepoName.MatchString(repo) {
		return "", "", fmt.Errorf("gitexec: invalid repo name %q", repo)
	}
	return verb, repo, nil
}

// validRepoName allows "owner/name" style paths, alnum/-/_ segments only, to
// keep path traversal out of ResolvePath.
var validRepoName = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$`)

// ResolvePath maps a repo name to its bare-repo path under reposRoot.
func ResolvePath(reposRoot, repoName string) (string, error) {
	if !validRepoName.MatchString(repoName) || strings.Contains(repoName, "..") {
		return "", fmt.Errorf("gitexec: invalid repo name %q", repoName)
	}
	return filepath.Join(reposRoot, repoName+".git"), nil
}

// InitBareRepo creates a new bare repository at path if it doesn't exist.
func InitBareRepo(path string) error {
	if _, err := os.Stat(path); err == nil {
		return fmt.Errorf("gitexec: repo already exists at %s", path)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}
	cmd := exec.Command("git", "init", "--bare", path)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("gitexec: git init --bare: %w: %s", err, out)
	}

	// Match the branch name modern git clients actually push by default,
	// rather than whatever this machine's git happens to default new
	// repos to — otherwise HEAD can point at a branch nobody ever pushes
	// (resolveDefaultRef below falls back gracefully either way, but
	// there's no reason to rely on the fallback when we can just get it
	// right at creation time).
	symCmd := exec.Command("git", "--git-dir="+path, "symbolic-ref", "HEAD", "refs/heads/main")
	if out, err := symCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("gitexec: set default branch: %w: %s", err, out)
	}
	return nil
}

// CloneMirror clones the repository at sourceURL into a new bare repo at
// path (one-shot import — see ROADMAP.md §3, "distinguished from continuous
// mirroring"), then drops the resulting "origin" remote so the bare repo
// never tries to re-contact the source on its own; nothing here ever fetches
// from it again. The caller must validate sourceURL first (scheme, host) —
// this only adds --end-of-options so a hostile URL starting with "-" can't
// be reinterpreted as a git flag, the same defense used everywhere else a
// user-controlled string reaches a git subprocess.
func CloneMirror(ctx context.Context, path, sourceURL string) error {
	if _, err := os.Stat(path); err == nil {
		return fmt.Errorf("gitexec: repo already exists at %s", path)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}

	cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", "--quiet", "--end-of-options", sourceURL, path)
	// Never prompt for credentials — an auth-required source must fail
	// fast, not hang the request until the context times out.
	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=")
	if out, err := cmd.CombinedOutput(); err != nil {
		_ = os.RemoveAll(path)
		return fmt.Errorf("gitexec: git clone --mirror: %w: %s", err, out)
	}

	if out, err := exec.Command("git", "--git-dir="+path, "remote", "remove", "origin").CombinedOutput(); err != nil {
		_ = os.RemoveAll(path)
		return fmt.Errorf("gitexec: detach origin remote: %w: %s", err, out)
	}

	// A mirrored HEAD is copied verbatim from sourceURL, which can point at
	// a branch that doesn't actually exist among what got mirrored (a
	// common real case: the source's default branch was renamed at some
	// point — e.g. master to main — and its own HEAD metadata never caught
	// up). git upload-pack refuses to advertise a symref for a dangling
	// HEAD, which breaks `git clone` for anyone downstream even though the
	// repo has perfectly good content — same underlying issue
	// TestPushToMismatchedDefaultBranch documents for InitBareRepo, just
	// arriving via a different path here.
	if err := fixDanglingHead(path); err != nil {
		_ = os.RemoveAll(path)
		return fmt.Errorf("gitexec: fix HEAD after mirror: %w", err)
	}
	return nil
}

// fixDanglingHead repoints HEAD at the repo's one unambiguous branch when
// HEAD itself doesn't resolve (see resolveDefaultRef) — a no-op when HEAD
// is already valid, and a deliberate no-op, not an error, when the repo has
// zero or multiple branches, since there's nothing safe to guess there.
func fixDanglingHead(path string) error {
	ref, ok := resolveDefaultRef(path)
	if !ok || ref == "HEAD" {
		return nil
	}
	out, err := exec.Command("git", "--git-dir="+path, "symbolic-ref", "HEAD", ref).CombinedOutput()
	if err != nil {
		return fmt.Errorf("symbolic-ref HEAD %s: %w: %s", ref, err, out)
	}
	return nil
}

// Serve runs the requested git service against path, wiring stdin/stdout to
// the SSH channel and stderr to the given writer.
func Serve(verb Verb, path string, stdin io.Reader, stdout, stderr io.Writer) error {
	if _, err := os.Stat(path); err != nil {
		return fmt.Errorf("gitexec: repo not found at %s: %w", path, err)
	}

	var args []string
	switch verb {
	case UploadPack:
		args = []string{"upload-pack", "--strict", path}
	case ReceivePack:
		args = []string{"receive-pack", path}
	default:
		return fmt.Errorf("gitexec: unknown verb %q", verb)
	}

	cmd := exec.Command("git", args...)
	cmd.Stdin = stdin
	cmd.Stdout = stdout
	cmd.Stderr = stderr
	return cmd.Run()
}

// resolveDefaultRef returns the tree-ish gitfed treats as this repo's
// default branch. It tries HEAD first, but a bare repo's HEAD symref is set
// once at creation time (see InitBareRepo) and doesn't move just because a
// client pushes a differently-named branch — so HEAD can end up dangling
// (pointing at a branch that was never actually pushed) even for a repo
// that clearly has real content on some other branch. When that happens,
// "main" or "master" wins if either exists (the two names any repo is
// realistically going to use), and otherwise the alphabetically-first
// branch — an arbitrary but deterministic pick beats declaring the repo
// empty just because HEAD points nowhere. ok is false only when there are
// no branches at all.
func resolveDefaultRef(repoPath string) (ref string, ok bool) {
	if exec.Command("git", "--git-dir="+repoPath, "rev-parse", "--verify", "-q", "HEAD").Run() == nil {
		return "HEAD", true
	}
	out, err := exec.Command("git", "--git-dir="+repoPath, "for-each-ref", "--format=%(refname)", "refs/heads/").Output()
	if err != nil {
		return "", false
	}
	refs := strings.Fields(string(out))
	if len(refs) == 0 {
		return "", false
	}
	for _, preferred := range [2]string{"refs/heads/main", "refs/heads/master"} {
		for _, r := range refs {
			if r == preferred {
				return preferred, true
			}
		}
	}
	sort.Strings(refs)
	return refs[0], true
}

// DefaultBranchName returns the short branch name (e.g. "main") backing
// resolveDefaultRef, for display purposes. ok is false under the same
// conditions as resolveDefaultRef.
func DefaultBranchName(repoPath string) (string, bool) {
	ref, ok := resolveDefaultRef(repoPath)
	if !ok {
		return "", false
	}
	if ref == "HEAD" {
		out, err := exec.Command("git", "--git-dir="+repoPath, "symbolic-ref", "--short", "HEAD").Output()
		if err != nil {
			return "", false
		}
		return strings.TrimSpace(string(out)), true
	}
	return strings.TrimPrefix(ref, "refs/heads/"), true
}

// ReadFileAtHEAD returns the content of filename as it exists in the tree at
// the repo's default branch (see resolveDefaultRef). found is false (with a
// nil error) if the repo has no commits yet or the file doesn't exist there
// — both are expected, unremarkable states for e.g. an optional README.
func ReadFileAtHEAD(repoPath, filename string) (content string, found bool, err error) {
	ref, ok := resolveDefaultRef(repoPath)
	if !ok {
		return "", false, nil
	}
	return ReadFileAtRef(repoPath, ref, filename)
}

// ReadFileAtRef is ReadFileAtHEAD against an explicit ref (e.g. a non-default
// branch someone picked from the repo page's branch dropdown) instead of the
// repo's default branch. The caller is responsible for ref being a real,
// known ref (e.g. checked against ListBranches) — --end-of-options is only
// defense in depth against a dash-prefixed value being reinterpreted as a
// git flag, not a substitute for that check.
func ReadFileAtRef(repoPath, ref, filename string) (content string, found bool, err error) {
	filename = strings.TrimPrefix(filename, "/")
	cmd := exec.Command("git", "--git-dir="+repoPath, "show", "--end-of-options", ref+":"+filename)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		if notFoundGitError(stderr.String()) {
			return "", false, nil
		}
		return "", false, fmt.Errorf("gitexec: read %s at %s: %w: %s", filename, ref, err, stderr.String())
	}
	return stdout.String(), true, nil
}

// TreeEntry is one immediate child of a directory in the repo's tree.
type TreeEntry struct {
	Name string `json:"name"`
	Type string `json:"type"` // "blob" (file) or "tree" (directory)
}

func notFoundGitError(msg string) bool {
	return strings.Contains(msg, "does not exist") ||
		strings.Contains(msg, "bad revision") ||
		strings.Contains(msg, "bad object") ||
		strings.Contains(msg, "not in the tree") ||
		strings.Contains(msg, "invalid object name") ||
		strings.Contains(msg, "valid object name") ||
		strings.Contains(msg, "not a tree object") ||
		strings.Contains(msg, "relative path syntax")
}

// ListTree returns the immediate children (files and directories) at path
// in the tree at the repo's default branch (see resolveDefaultRef),
// directories first then files, alphabetically within each. path == "" lists
// the repo root. found is false (nil error) for an empty repo or a path
// that doesn't exist — both unremarkable.
func ListTree(repoPath, path string) (entries []TreeEntry, found bool, err error) {
	ref, ok := resolveDefaultRef(repoPath)
	if !ok {
		return nil, false, nil
	}
	return ListTreeAtRef(repoPath, ref, path)
}

// ListTreeAtRef is ListTree against an explicit ref instead of the repo's
// default branch — same caller-must-validate-ref contract as ReadFileAtRef.
func ListTreeAtRef(repoPath, ref, path string) (entries []TreeEntry, found bool, err error) {
	path = strings.Trim(path, "/")
	treeish := ref
	if path != "" {
		treeish = ref + ":" + path
	}

	cmd := exec.Command("git", "--git-dir="+repoPath, "ls-tree", "--end-of-options", treeish)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		if notFoundGitError(stderr.String()) {
			return nil, false, nil
		}
		return nil, false, fmt.Errorf("gitexec: list tree at %q: %w: %s", path, err, stderr.String())
	}

	for _, line := range strings.Split(stdout.String(), "\n") {
		if line == "" {
			continue
		}
		tab := strings.IndexByte(line, '\t')
		if tab < 0 {
			continue
		}
		meta := strings.Fields(line[:tab])
		if len(meta) < 2 {
			continue
		}
		entries = append(entries, TreeEntry{Name: line[tab+1:], Type: meta[1]})
	}
	sort.Slice(entries, func(i, j int) bool {
		if (entries[i].Type == "tree") != (entries[j].Type == "tree") {
			return entries[i].Type == "tree"
		}
		return entries[i].Name < entries[j].Name
	})
	return entries, true, nil
}

// ListTags returns the repo's git tags, most recently created first.
func ListTags(repoPath string) ([]string, error) {
	cmd := exec.Command("git", "--git-dir="+repoPath, "tag", "--list", "--sort=-creatordate")
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("gitexec: list tags: %w: %s", err, stderr.String())
	}
	var tags []string
	for _, line := range strings.Split(stdout.String(), "\n") {
		if line = strings.TrimSpace(line); line != "" {
			tags = append(tags, line)
		}
	}
	return tags, nil
}

// archiveMaxBytes caps how large an archive Archive will return. Needed
// because — unlike a normal git subprocess call in this file — the result
// crosses the admin RPC boundary as a single buffered, base64-encoded JSON
// response (internal/adminrpc has no streaming transport; see
// internal/adminrpc/client.go's call()), so an unbounded archive would mean
// an unbounded in-memory buffer on both ends. Refusing outright past this
// size is deliberate: a truncated tar/zip is corrupt and useless, so there
// is no sensible partial result the way CommitDiff can return a truncated
// patch.
const archiveMaxBytes = 100 * 1024 * 1024

// Archive returns a tar.gz or zip snapshot of ref (a branch or tag name —
// the caller is responsible for ref being real, same contract as
// ListTreeAtRef/ReadFileAtRef) via `git archive`, hardened with
// --end-of-options for the same reason as everywhere else a ref reaches a
// git subprocess.
func Archive(repoPath, ref, format string) ([]byte, error) {
	if format != "tar.gz" && format != "zip" {
		return nil, fmt.Errorf("gitexec: unsupported archive format %q", format)
	}

	cmd := exec.Command("git", "--git-dir="+repoPath, "archive", "--format="+format, "--end-of-options", ref)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Start(); err != nil {
		return nil, err
	}

	data, readErr := io.ReadAll(io.LimitReader(stdout, archiveMaxBytes+1))
	if readErr != nil {
		_ = cmd.Wait()
		return nil, fmt.Errorf("gitexec: archive %s: %w", ref, readErr)
	}
	if len(data) > archiveMaxBytes {
		_ = cmd.Process.Kill()
		_ = cmd.Wait()
		return nil, fmt.Errorf("gitexec: archive for %s exceeds %d bytes, refusing", ref, archiveMaxBytes)
	}
	if err := cmd.Wait(); err != nil {
		return nil, fmt.Errorf("gitexec: archive %s: %w: %s", ref, err, stderr.String())
	}
	return data, nil
}

// Commit is one entry in a repo's history, as shown by ListCommits.
type Commit struct {
	Hash      string    `json:"hash"`
	ShortHash string    `json:"short_hash"`
	Author    string    `json:"author"`
	Email     string    `json:"email"`
	Date      time.Time `json:"date"`
	Subject   string    `json:"subject"` // first line of the commit message only
}

// commitFieldSep and commitRecordSep are ASCII unit/record separators
// (0x1f/0x1e) — control characters that can't appear in a commit's own
// metadata, so they safely delimit fields/records no matter what a commit
// subject line itself contains (unlike a printable character such as "|").
const commitFieldSep, commitRecordSep = "\x1f", "\x1e"

// CountCommits returns how many commits are reachable from the repo's
// default branch (see resolveDefaultRef). found is false (nil error) for a
// repo with no commits yet, matching ListCommits's contract.
func CountCommits(repoPath string) (count int, found bool, err error) {
	ref, ok := resolveDefaultRef(repoPath)
	if !ok {
		return 0, false, nil
	}

	out, err := exec.Command("git", "--git-dir="+repoPath, "rev-list", "--count", ref).Output()
	if err != nil {
		return 0, false, fmt.Errorf("gitexec: rev-list --count %s: %w", ref, err)
	}
	n, err := strconv.Atoi(strings.TrimSpace(string(out)))
	if err != nil {
		return 0, false, fmt.Errorf("gitexec: parse rev-list --count output %q: %w", out, err)
	}
	return n, true, nil
}

// CountContributors returns the number of distinct commit authors (by
// email — the same identity git itself groups by) reachable from the
// repo's default branch. found is false (nil error) for a repo with no
// commits yet, same contract as CountCommits.
func CountContributors(repoPath string) (count int, found bool, err error) {
	ref, ok := resolveDefaultRef(repoPath)
	if !ok {
		return 0, false, nil
	}

	out, err := exec.Command("git", "--git-dir="+repoPath, "log", ref, "--format=%ae").Output()
	if err != nil {
		return 0, false, fmt.Errorf("gitexec: log --format=%%ae %s: %w", ref, err)
	}
	seen := map[string]bool{}
	for _, line := range strings.Split(string(out), "\n") {
		if line = strings.TrimSpace(line); line != "" {
			seen[line] = true
		}
	}
	return len(seen), true, nil
}

// languageByExtension is a small, deliberately incomplete file-extension ->
// display-name table for DominantLanguage — same "lightweight heuristic,
// not a full linguist-style analyzer" spirit as detectLicenseType in
// cmd/gitfed-web/license.go, not an exhaustive/authoritative list.
var languageByExtension = map[string]string{
	".go": "Go", ".py": "Python", ".js": "JavaScript", ".mjs": "JavaScript", ".jsx": "JavaScript",
	".ts": "TypeScript", ".tsx": "TypeScript", ".rb": "Ruby", ".java": "Java", ".c": "C", ".h": "C",
	".cpp": "C++", ".cc": "C++", ".hpp": "C++", ".rs": "Rust", ".php": "PHP", ".sh": "Shell",
	".bash": "Shell", ".css": "CSS", ".scss": "SCSS", ".html": "HTML", ".htm": "HTML", ".sql": "SQL",
	".lua": "Lua", ".swift": "Swift", ".kt": "Kotlin", ".pl": "Perl", ".cs": "C#", ".ex": "Elixir",
	".exs": "Elixir", ".erl": "Erlang", ".hs": "Haskell", ".scala": "Scala", ".clj": "Clojure",
	".r": "R", ".m": "Objective-C", ".dart": "Dart", ".zig": "Zig",
}

// DominantLanguage returns the most common recognized programming language
// among the files in the repo's default-branch tree, counted by file count
// (not byte size — simpler, and consistent with this being a cosmetic
// estimate, not a real linguist-style analysis). found is false (nil
// error) for an empty repo or one with no recognized-language files (e.g.
// all Markdown/config).
func DominantLanguage(repoPath string) (language string, found bool, err error) {
	ref, ok := resolveDefaultRef(repoPath)
	if !ok {
		return "", false, nil
	}

	out, err := exec.Command("git", "--git-dir="+repoPath, "ls-tree", "-r", "--name-only", "--end-of-options", ref).Output()
	if err != nil {
		return "", false, fmt.Errorf("gitexec: ls-tree -r %s: %w", ref, err)
	}

	counts := map[string]int{}
	for _, path := range strings.Split(string(out), "\n") {
		if lang, ok := languageByExtension[strings.ToLower(filepath.Ext(path))]; ok {
			counts[lang]++
		}
	}

	var best string
	var bestCount int
	for lang, n := range counts {
		if n > bestCount || (n == bestCount && lang < best) {
			best, bestCount = lang, n
		}
	}
	return best, best != "", nil
}

// DiskUsage returns the total size in bytes of every regular file under
// repoPath. Repos are always bare (see InitBareRepo) — the whole directory
// *is* the repo's storage (packfiles, refs, objects), there's no separate
// working tree to exclude.
func DiskUsage(repoPath string) (int64, error) {
	var total int64
	err := filepath.WalkDir(repoPath, func(_ string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.Type().IsRegular() {
			info, err := d.Info()
			if err != nil {
				return err
			}
			total += info.Size()
		}
		return nil
	})
	if err != nil {
		return 0, fmt.Errorf("gitexec: disk usage for %s: %w", repoPath, err)
	}
	return total, nil
}

// ListCommits returns up to limit commits reachable from the repo's default
// branch (see resolveDefaultRef), most recent first. found is false (nil
// error) for a repo with no commits yet.
func ListCommits(repoPath string, limit int) (commits []Commit, found bool, err error) {
	return ListCommitsPage(repoPath, limit, 0)
}

// ListCommitsPage is ListCommits with an additional offset (via `git log
// --skip=`), for the commits page's pagination — most recent first, so
// offset 0 is always the newest page regardless of how many commits have
// landed since a caller last asked.
func ListCommitsPage(repoPath string, limit, offset int) (commits []Commit, found bool, err error) {
	ref, ok := resolveDefaultRef(repoPath)
	if !ok {
		return nil, false, nil
	}

	format := strings.Join([]string{"%H", "%h", "%an", "%ae", "%aI", "%s"}, commitFieldSep) + commitRecordSep
	args := []string{"--git-dir=" + repoPath, "log", "--max-count=" + strconv.Itoa(limit)}
	if offset > 0 {
		args = append(args, "--skip="+strconv.Itoa(offset))
	}
	args = append(args, "--format="+format, ref)

	cmd := exec.Command("git", args...)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, false, fmt.Errorf("gitexec: log %s: %w: %s", ref, err, stderr.String())
	}

	for _, record := range strings.Split(stdout.String(), commitRecordSep) {
		record = strings.TrimPrefix(record, "\n")
		if record == "" {
			continue
		}
		f := strings.Split(record, commitFieldSep)
		if len(f) != 6 {
			continue
		}
		date, _ := time.Parse(time.RFC3339, f[4])
		commits = append(commits, Commit{
			Hash: f[0], ShortHash: f[1], Author: f[2], Email: f[3], Date: date, Subject: f[5],
		})
	}
	return commits, true, nil
}

// DiffFile summarizes one file changed by a commit.
type DiffFile struct {
	Path       string
	Status     string // "added", "modified", "deleted", "renamed", "copied"
	Insertions int
	Deletions  int
	Binary     bool
}

// CommitDetail is a single commit's full metadata plus the files it changed.
type CommitDetail struct {
	Commit
	Body    string   // commit message with the subject line removed, trimmed
	Parents []string // full parent hashes, empty for a root commit
	Files   []DiffFile
}

// commitDiffMaxLines caps how many lines of patch text CommitDiff returns,
// so one huge commit can't make gitfed-web buffer an unbounded amount of
// memory rendering it.
const commitDiffMaxLines = 4000

// ShowCommit returns a commit's full metadata and changed-file summary.
// found is false (nil error) if hash doesn't resolve to a commit in
// repoPath.
func ShowCommit(repoPath, hash string) (detail CommitDetail, found bool, err error) {
	format := strings.Join([]string{"%H", "%h", "%an", "%ae", "%aI", "%P"}, commitFieldSep) + commitFieldSep + "%B"
	cmd := exec.Command("git", "--git-dir="+repoPath, "show", "--no-patch", "--format="+format, "--end-of-options", hash)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		if notFoundGitError(stderr.String()) {
			return CommitDetail{}, false, nil
		}
		return CommitDetail{}, false, fmt.Errorf("gitexec: show %s: %w: %s", hash, err, stderr.String())
	}

	out := strings.TrimRight(stdout.String(), "\n")
	f := strings.SplitN(out, commitFieldSep, 7)
	if len(f) != 7 {
		return CommitDetail{}, false, fmt.Errorf("gitexec: show %s: unexpected output", hash)
	}
	date, _ := time.Parse(time.RFC3339, f[4])
	var parents []string
	if p := strings.TrimSpace(f[5]); p != "" {
		parents = strings.Fields(p)
	}
	subject, body := splitCommitMessage(f[6])

	files, err := commitFiles(repoPath, hash)
	if err != nil {
		return CommitDetail{}, false, err
	}

	return CommitDetail{
		Commit:  Commit{Hash: f[0], ShortHash: f[1], Author: f[2], Email: f[3], Date: date, Subject: subject},
		Body:    body,
		Parents: parents,
		Files:   files,
	}, true, nil
}

func splitCommitMessage(raw string) (subject, body string) {
	raw = strings.TrimRight(raw, "\n")
	parts := strings.SplitN(raw, "\n", 2)
	subject = parts[0]
	if len(parts) == 2 {
		body = strings.TrimSpace(parts[1])
	}
	return subject, body
}

func statusName(code byte) string {
	switch code {
	case 'A':
		return "added"
	case 'D':
		return "deleted"
	case 'R':
		return "renamed"
	case 'C':
		return "copied"
	default:
		return "modified"
	}
}

// commitFiles lists the files a commit touched, combining --name-status
// (for the change type) and --numstat (for line counts) by position: both
// invocations walk the same diff in the same deterministic order, so the
// Nth line of each always describes the same file.
func commitFiles(repoPath, hash string) ([]DiffFile, error) {
	statusLines, err := runGitLines(repoPath, "show", "--format=", "--name-status", "--end-of-options", hash)
	if err != nil {
		return nil, err
	}
	numLines, err := runGitLines(repoPath, "show", "--format=", "--numstat", "--end-of-options", hash)
	if err != nil {
		return nil, err
	}
	return mergeStatusAndNumstat(statusLines, numLines), nil
}

// branchFiles lists the files that merging source into target would touch
// — the same name-status/numstat combination as commitFiles, but diffing
// against the merge base of the two branches (git's "..." range syntax)
// rather than a single commit's parent.
func branchFiles(repoPath, target, source string) ([]DiffFile, error) {
	rangeSpec := target + "..." + source
	statusLines, err := runGitLines(repoPath, "diff", "--name-status", "--end-of-options", rangeSpec)
	if err != nil {
		return nil, err
	}
	numLines, err := runGitLines(repoPath, "diff", "--numstat", "--end-of-options", rangeSpec)
	if err != nil {
		return nil, err
	}
	return mergeStatusAndNumstat(statusLines, numLines), nil
}

func mergeStatusAndNumstat(statusLines, numLines []string) []DiffFile {
	files := make([]DiffFile, 0, len(statusLines))
	for i, line := range statusLines {
		f := strings.Split(line, "\t")
		if len(f) < 2 {
			continue
		}
		code, path := f[0], f[len(f)-1]
		if (code[0] == 'R' || code[0] == 'C') && len(f) >= 3 {
			path = f[1] + " → " + f[2]
		}

		var add, del int
		var binary bool
		if i < len(numLines) {
			nf := strings.Split(numLines[i], "\t")
			if len(nf) >= 2 {
				if nf[0] == "-" && nf[1] == "-" {
					binary = true
				} else {
					add, _ = strconv.Atoi(nf[0])
					del, _ = strconv.Atoi(nf[1])
				}
			}
		}
		files = append(files, DiffFile{Path: path, Status: statusName(code[0]), Insertions: add, Deletions: del, Binary: binary})
	}
	return files
}

func runGitLines(repoPath string, args ...string) ([]string, error) {
	cmd := exec.Command("git", append([]string{"--git-dir=" + repoPath}, args...)...)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("gitexec: %s: %w: %s", strings.Join(args, " "), err, stderr.String())
	}
	var lines []string
	for _, l := range strings.Split(stdout.String(), "\n") {
		if l != "" {
			lines = append(lines, l)
		}
	}
	return lines, nil
}

// CommitDiff returns the unified diff patch text for a commit (all files),
// capped at commitDiffMaxLines lines. truncated reports whether the cap
// was hit.
func CommitDiff(repoPath, hash string) (diff string, truncated bool, err error) {
	cmd := exec.Command("git", "--git-dir="+repoPath, "show", "--format=", "--no-color", "--end-of-options", hash)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return "", false, fmt.Errorf("gitexec: diff %s: %w: %s", hash, err, stderr.String())
	}
	return truncateDiff(stdout.String()), len(strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")) > commitDiffMaxLines, nil
}

func truncateDiff(patch string) string {
	lines := strings.Split(strings.TrimRight(patch, "\n"), "\n")
	if len(lines) > commitDiffMaxLines {
		lines = lines[:commitDiffMaxLines]
	}
	return strings.Join(lines, "\n")
}

// ListBranches returns the repo's branch names, alphabetically.
func ListBranches(repoPath string) ([]string, error) {
	lines, err := runGitLines(repoPath, "for-each-ref", "--format=%(refname:short)", "--sort=refname", "refs/heads/")
	if err != nil {
		return nil, err
	}
	return lines, nil
}

// BranchDiff returns the changed-file summary and unified diff patch for
// what merging source into target would introduce — diffed against their
// merge base (git's "..." range), the same comparison a merge itself
// would make, not a flat two-tree diff. found is false (nil error) if
// either branch doesn't exist.
func BranchDiff(repoPath, target, source string) (files []DiffFile, diff string, truncated bool, found bool, err error) {
	for _, ref := range []string{target, source} {
		if exec.Command("git", "--git-dir="+repoPath, "rev-parse", "--verify", "-q", "refs/heads/"+ref).Run() != nil {
			return nil, "", false, false, nil
		}
	}

	files, err = branchFiles(repoPath, target, source)
	if err != nil {
		return nil, "", false, false, err
	}

	cmd := exec.Command("git", "--git-dir="+repoPath, "diff", "--no-color", "--end-of-options", target+"..."+source)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, "", false, false, fmt.Errorf("gitexec: diff %s...%s: %w: %s", target, source, err, stderr.String())
	}
	full := stdout.String()
	return files, truncateDiff(full), len(strings.Split(strings.TrimRight(full, "\n"), "\n")) > commitDiffMaxLines, true, nil
}

// MergeResult reports the outcome of a mergeability check or an attempted
// merge: either Clean, or blocked with the specific files that conflict.
type MergeResult struct {
	Clean         bool
	ConflictFiles []string
}

// withScratchWorktree runs fn against a throwaway linked worktree checked
// out at base, then always removes it — used so mergeability checks and
// real merges never touch the repo's actual branch refs or working state
// except through the explicit update-ref in MergeBranches.
func withScratchWorktree(repoPath, base string, fn func(dir string) error) error {
	dir, err := os.MkdirTemp("", "gitfed-merge-")
	if err != nil {
		return fmt.Errorf("gitexec: scratch worktree: %w", err)
	}
	if err := os.Remove(dir); err != nil {
		return fmt.Errorf("gitexec: scratch worktree: %w", err)
	}
	defer func() {
		_ = exec.Command("git", "--git-dir="+repoPath, "worktree", "remove", "--force", dir).Run()
		_ = os.RemoveAll(dir)
	}()

	addCmd := exec.Command("git", "--git-dir="+repoPath, "worktree", "add", "--detach", "--quiet", "--end-of-options", dir, base)
	var stderr bytes.Buffer
	addCmd.Stderr = &stderr
	if err := addCmd.Run(); err != nil {
		return fmt.Errorf("gitexec: worktree add %s: %w: %s", base, err, stderr.String())
	}
	return fn(dir)
}

func conflictedFiles(dir string) []string {
	out, err := exec.Command("git", "-C", dir, "diff", "--name-only", "--diff-filter=U").Output()
	if err != nil {
		return nil
	}
	var files []string
	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		if l != "" {
			files = append(files, l)
		}
	}
	return files
}

// CheckMergeable reports whether source merges cleanly into target,
// without touching any real ref — the attempt happens in a scratch
// worktree that's discarded either way.
func CheckMergeable(repoPath, target, source string) (MergeResult, error) {
	var result MergeResult
	err := withScratchWorktree(repoPath, target, func(dir string) error {
		cmd := exec.Command("git", "-C", dir, "-c", "user.name=gitfed", "-c", "user.email=gitfed@localhost",
			"merge", "--no-commit", "--no-ff", "--quiet", "--end-of-options", source)
		var stderr bytes.Buffer
		cmd.Stderr = &stderr
		if runErr := cmd.Run(); runErr != nil {
			if conflicts := conflictedFiles(dir); len(conflicts) > 0 {
				result.ConflictFiles = conflicts
				return nil
			}
			return fmt.Errorf("gitexec: merge check %s into %s: %w: %s", source, target, runErr, stderr.String())
		}
		result.Clean = true
		return nil
	})
	return result, err
}

// MergeBranches merges source into target with a real merge commit
// (always --no-ff, so the merge is traceable even when a fast-forward
// would have sufficed) and atomically updates refs/heads/target via
// compare-and-swap: if target moved (e.g. a concurrent push) between the
// merge being computed and the ref update, the update is rejected instead
// of silently discarding that commit. The merge itself happens in a
// scratch worktree so target's checked-out state, if any, is never
// touched directly.
func MergeBranches(repoPath, target, source, message, authorName, authorEmail string) (mergeCommit string, result MergeResult, err error) {
	oldTip, err := revParse(repoPath, "refs/heads/"+target)
	if err != nil {
		return "", MergeResult{}, err
	}

	err = withScratchWorktree(repoPath, target, func(dir string) error {
		cmd := exec.Command("git", "-C", dir,
			"-c", "user.name="+authorName, "-c", "user.email="+authorEmail,
			"merge", "--no-ff", "--quiet", "-m", message, "--end-of-options", source)
		var stderr bytes.Buffer
		cmd.Stderr = &stderr
		if runErr := cmd.Run(); runErr != nil {
			if conflicts := conflictedFiles(dir); len(conflicts) > 0 {
				result.ConflictFiles = conflicts
				return nil
			}
			return fmt.Errorf("gitexec: merge %s into %s: %w: %s", source, target, runErr, stderr.String())
		}
		result.Clean = true
		tip, revErr := headOf(dir)
		if revErr != nil {
			return revErr
		}
		mergeCommit = tip
		return nil
	})
	if err != nil || !result.Clean {
		return "", result, err
	}

	updateCmd := exec.Command("git", "--git-dir="+repoPath, "update-ref", "refs/heads/"+target, mergeCommit, oldTip)
	var stderr bytes.Buffer
	updateCmd.Stderr = &stderr
	if err := updateCmd.Run(); err != nil {
		return "", result, fmt.Errorf("gitexec: update-ref %s (concurrent push?): %w: %s", target, err, stderr.String())
	}
	return mergeCommit, result, nil
}

func revParse(repoPath, ref string) (string, error) {
	cmd := exec.Command("git", "--git-dir="+repoPath, "rev-parse", ref)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return "", fmt.Errorf("gitexec: rev-parse %s: %w: %s", ref, err, stderr.String())
	}
	return strings.TrimSpace(stdout.String()), nil
}

// headOf resolves HEAD inside a normal (non-bare) working directory, such
// as a scratch worktree — unlike revParse, this uses -C instead of
// --git-dir since dir is a worktree, not the bare repo itself.
func headOf(dir string) (string, error) {
	cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD")
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return "", fmt.Errorf("gitexec: rev-parse HEAD in %s: %w: %s", dir, err, stderr.String())
	}
	return strings.TrimSpace(stdout.String()), nil
}