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 }