josie / simplegit

package git

import (
	"bytes"
	"errors"
	"fmt"
	"os/exec"
	"strings"
)

// ErrMergeConflict marks a merge that cannot complete without resolving
// conflicts by hand.
var ErrMergeConflict = errors.New("merge conflict")

// ErrAlreadyMerged marks a head whose changes are already contained in base.
var ErrAlreadyMerged = errors.New("already merged")

// ErrNoCommonAncestor marks two revisions with unrelated histories.
var ErrNoCommonAncestor = errors.New("no common ancestor")

// ResolveCommit resolves rev (branch, tag, sha) to a full commit SHA, or
// ErrNotFound when it does not name a commit.
func ResolveCommit(repoPath, rev string) (string, error) {
	cmd := gitCommand(repoPath, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		var ee *exec.ExitError
		if errors.As(err, &ee) && ee.ExitCode() == 1 {
			return "", fmt.Errorf("rev %s: %w", rev, ErrNotFound)
		}
		return "", fmt.Errorf("git rev-parse %s in %s: %w: %s", rev, repoPath, err, strings.TrimSpace(stderr.String()))
	}
	return strings.TrimSpace(string(out)), nil
}

// refsHeads is the branch-ref prefix used to build explicit ref arguments.
const refsHeads = "refs/heads/"

// Branches lists the local branch names (refs/heads), sorted.
func Branches(repoPath string) ([]string, error) {
	return forEachRefNames(repoPath, refsHeads)
}

// MergeBase returns the best common ancestor of a and b, or
// ErrNoCommonAncestor when the histories are unrelated.
func MergeBase(repoPath, a, b string) (string, error) {
	cmd := gitCommand(repoPath, "merge-base", a, b)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		var ee *exec.ExitError
		if errors.As(err, &ee) && ee.ExitCode() == 1 {
			return "", fmt.Errorf("merge-base %s %s: %w", a, b, ErrNoCommonAncestor)
		}
		return "", fmt.Errorf("git merge-base %s %s: %w: %s", a, b, err, strings.TrimSpace(stderr.String()))
	}
	return strings.TrimSpace(string(out)), nil
}

// Diff returns the patch that head introduces on top of its merge base with
// base (git's three-dot form), renames detected and colour suppressed.
// At most limit+1 bytes are read; callers detect the cap with len.
func Diff(repoPath, base, head string, limit int) ([]byte, error) {
	cmd := gitCommand(repoPath, "diff", "--no-color", "--patch", "--find-renames", base+"..."+head)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, oversized, err := runBounded(cmd, limit)
	if oversized {
		return out, nil
	}
	if err != nil {
		return nil, fmt.Errorf("git diff %s...%s: %w: %s", base, head, err, strings.TrimSpace(stderr.String()))
	}
	return out, nil
}

// Merge advances the base branch to include head: a fast-forward when base
// is an ancestor of head, otherwise a two-parent merge commit. It updates
// refs/heads/base and reports the new head SHA and whether it fast-forwarded.
// authorName becomes the merge commit's author/committer (the host has no
// global git identity to inherit).
func Merge(repoPath, base, head, message, authorName string) (sha string, fastForward bool, err error) {
	baseSHA, err := ResolveCommit(repoPath, refsHeads+base)
	if err != nil {
		return "", false, fmt.Errorf("merge base %s: %w", base, err)
	}
	headSHA, err := ResolveCommit(repoPath, refsHeads+head)
	if err != nil {
		return "", false, fmt.Errorf("merge head %s: %w", head, err)
	}

	mergeBase, err := MergeBase(repoPath, baseSHA, headSHA)
	if err != nil {
		return "", false, err
	}
	if mergeBase == baseSHA {
		if err := updateRef(repoPath, refsHeads+base, headSHA); err != nil {
			return "", false, err
		}
		return headSHA, true, nil
	}
	if mergeBase == headSHA {
		// head is already an ancestor of base: its changes are contained.
		return headSHA, true, ErrAlreadyMerged
	}

	tree, conflict, err := mergeTree(repoPath, baseSHA, headSHA)
	if err != nil {
		return "", false, err
	}
	if conflict {
		return "", false, fmt.Errorf("merge %s into %s: %w", head, base, ErrMergeConflict)
	}
	commit, err := commitTree(repoPath, tree, baseSHA, headSHA, message, authorName)
	if err != nil {
		return "", false, err
	}
	if err := updateRef(repoPath, refsHeads+base, commit); err != nil {
		return "", false, err
	}
	return commit, false, nil
}

// mergeTree merges two commits without a work tree, writing the result tree
// and reporting whether the merge conflicted.
func mergeTree(repoPath, baseSHA, headSHA string) (tree string, conflict bool, err error) {
	cmd := gitCommand(repoPath, "merge-tree", "--write-tree", baseSHA, headSHA)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	runErr := cmd.Run()
	if runErr != nil {
		var ee *exec.ExitError
		if errors.As(runErr, &ee) && ee.ExitCode() == 1 {
			return "", true, nil
		}
		return "", false, fmt.Errorf("git merge-tree: %w: %s", runErr, strings.TrimSpace(stderr.String()))
	}
	line, _, _ := strings.Cut(stdout.String(), "\n")
	if line = strings.TrimSpace(line); line == "" {
		return "", false, errors.New("git merge-tree: no tree produced")
	}
	return line, false, nil
}

// commitTree creates a two-parent merge commit object.
func commitTree(repoPath, tree, parentA, parentB, message, authorName string) (string, error) {
	if authorName == "" {
		authorName = "simplegit"
	}
	cmd := gitCommand(repoPath, "commit-tree", tree, "-p", parentA, "-p", parentB, "-m", message)
	// gitCommand pins a minimal env with no identity and no HOME, so supply
	// one explicitly; the merge commit must record some author.
	cmd.Env = append(cmd.Env,
		"GIT_AUTHOR_NAME="+authorName,
		"GIT_AUTHOR_EMAIL="+authorName+"@simplegit",
		"GIT_COMMITTER_NAME="+authorName,
		"GIT_COMMITTER_EMAIL="+authorName+"@simplegit",
	)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("git commit-tree: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	return strings.TrimSpace(string(out)), nil
}

func updateRef(repoPath, ref, sha string) error {
	cmd := gitCommand(repoPath, "update-ref", ref, sha)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("git update-ref %s: %w: %s", ref, err, strings.TrimSpace(stderr.String()))
	}
	return nil
}