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