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
}