Operations: - Clone, Checkout, Fetch, Pull, Push - Add, Commit, CommitAll - Status, CurrentBranch, HasUncommittedChanges, RemoteURL - Merge, Rebase, AbortMerge, AbortRebase - CreateBranch, DeleteBranch, Rev, IsAncestor Error handling: - Detects ErrNotARepo, ErrMergeConflict, ErrAuthFailure, ErrRebaseConflict - Wraps git stderr in error messages Closes gt-u1j.3 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
249 lines
5.9 KiB
Go
249 lines
5.9 KiB
Go
// Package git provides a wrapper for git operations via subprocess.
|
|
package git
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// Common errors
|
|
var (
|
|
ErrNotARepo = errors.New("not a git repository")
|
|
ErrMergeConflict = errors.New("merge conflict")
|
|
ErrAuthFailure = errors.New("authentication failed")
|
|
ErrRebaseConflict = errors.New("rebase conflict")
|
|
)
|
|
|
|
// Git wraps git operations for a working directory.
|
|
type Git struct {
|
|
workDir string
|
|
}
|
|
|
|
// NewGit creates a new Git wrapper for the given directory.
|
|
func NewGit(workDir string) *Git {
|
|
return &Git{workDir: workDir}
|
|
}
|
|
|
|
// run executes a git command and returns stdout.
|
|
func (g *Git) run(args ...string) (string, error) {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = g.workDir
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return "", g.wrapError(err, stderr.String(), args)
|
|
}
|
|
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// wrapError wraps git errors with context.
|
|
func (g *Git) wrapError(err error, stderr string, args []string) error {
|
|
stderr = strings.TrimSpace(stderr)
|
|
|
|
// Detect specific error types
|
|
if strings.Contains(stderr, "not a git repository") {
|
|
return ErrNotARepo
|
|
}
|
|
if strings.Contains(stderr, "CONFLICT") || strings.Contains(stderr, "Merge conflict") {
|
|
return ErrMergeConflict
|
|
}
|
|
if strings.Contains(stderr, "Authentication failed") || strings.Contains(stderr, "could not read Username") {
|
|
return ErrAuthFailure
|
|
}
|
|
if strings.Contains(stderr, "needs merge") || strings.Contains(stderr, "rebase in progress") {
|
|
return ErrRebaseConflict
|
|
}
|
|
|
|
if stderr != "" {
|
|
return fmt.Errorf("git %s: %s", args[0], stderr)
|
|
}
|
|
return fmt.Errorf("git %s: %w", args[0], err)
|
|
}
|
|
|
|
// Clone clones a repository to the destination.
|
|
func (g *Git) Clone(url, dest string) error {
|
|
cmd := exec.Command("git", "clone", url, dest)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return g.wrapError(err, stderr.String(), []string{"clone", url})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Checkout checks out the given ref.
|
|
func (g *Git) Checkout(ref string) error {
|
|
_, err := g.run("checkout", ref)
|
|
return err
|
|
}
|
|
|
|
// Fetch fetches from the remote.
|
|
func (g *Git) Fetch(remote string) error {
|
|
_, err := g.run("fetch", remote)
|
|
return err
|
|
}
|
|
|
|
// Pull pulls from the remote branch.
|
|
func (g *Git) Pull(remote, branch string) error {
|
|
_, err := g.run("pull", remote, branch)
|
|
return err
|
|
}
|
|
|
|
// Push pushes to the remote branch.
|
|
func (g *Git) Push(remote, branch string, force bool) error {
|
|
args := []string{"push", remote, branch}
|
|
if force {
|
|
args = append(args, "--force")
|
|
}
|
|
_, err := g.run(args...)
|
|
return err
|
|
}
|
|
|
|
// Add stages files for commit.
|
|
func (g *Git) Add(paths ...string) error {
|
|
args := append([]string{"add"}, paths...)
|
|
_, err := g.run(args...)
|
|
return err
|
|
}
|
|
|
|
// Commit creates a commit with the given message.
|
|
func (g *Git) Commit(message string) error {
|
|
_, err := g.run("commit", "-m", message)
|
|
return err
|
|
}
|
|
|
|
// CommitAll stages all changes and commits.
|
|
func (g *Git) CommitAll(message string) error {
|
|
_, err := g.run("commit", "-am", message)
|
|
return err
|
|
}
|
|
|
|
// GitStatus represents the status of the working directory.
|
|
type GitStatus struct {
|
|
Clean bool
|
|
Modified []string
|
|
Added []string
|
|
Deleted []string
|
|
Untracked []string
|
|
}
|
|
|
|
// Status returns the current git status.
|
|
func (g *Git) Status() (*GitStatus, error) {
|
|
out, err := g.run("status", "--porcelain")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
status := &GitStatus{Clean: true}
|
|
if out == "" {
|
|
return status, nil
|
|
}
|
|
|
|
status.Clean = false
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if len(line) < 3 {
|
|
continue
|
|
}
|
|
code := line[:2]
|
|
file := line[3:]
|
|
|
|
switch {
|
|
case strings.Contains(code, "M"):
|
|
status.Modified = append(status.Modified, file)
|
|
case strings.Contains(code, "A"):
|
|
status.Added = append(status.Added, file)
|
|
case strings.Contains(code, "D"):
|
|
status.Deleted = append(status.Deleted, file)
|
|
case strings.Contains(code, "?"):
|
|
status.Untracked = append(status.Untracked, file)
|
|
}
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// CurrentBranch returns the current branch name.
|
|
func (g *Git) CurrentBranch() (string, error) {
|
|
return g.run("rev-parse", "--abbrev-ref", "HEAD")
|
|
}
|
|
|
|
// HasUncommittedChanges returns true if there are uncommitted changes.
|
|
func (g *Git) HasUncommittedChanges() (bool, error) {
|
|
status, err := g.Status()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return !status.Clean, nil
|
|
}
|
|
|
|
// RemoteURL returns the URL for the given remote.
|
|
func (g *Git) RemoteURL(remote string) (string, error) {
|
|
return g.run("remote", "get-url", remote)
|
|
}
|
|
|
|
// Merge merges the given branch into the current branch.
|
|
func (g *Git) Merge(branch string) error {
|
|
_, err := g.run("merge", branch)
|
|
return err
|
|
}
|
|
|
|
// Rebase rebases the current branch onto the given ref.
|
|
func (g *Git) Rebase(onto string) error {
|
|
_, err := g.run("rebase", onto)
|
|
return err
|
|
}
|
|
|
|
// AbortMerge aborts a merge in progress.
|
|
func (g *Git) AbortMerge() error {
|
|
_, err := g.run("merge", "--abort")
|
|
return err
|
|
}
|
|
|
|
// AbortRebase aborts a rebase in progress.
|
|
func (g *Git) AbortRebase() error {
|
|
_, err := g.run("rebase", "--abort")
|
|
return err
|
|
}
|
|
|
|
// CreateBranch creates a new branch.
|
|
func (g *Git) CreateBranch(name string) error {
|
|
_, err := g.run("branch", name)
|
|
return err
|
|
}
|
|
|
|
// DeleteBranch deletes a branch.
|
|
func (g *Git) DeleteBranch(name string, force bool) error {
|
|
flag := "-d"
|
|
if force {
|
|
flag = "-D"
|
|
}
|
|
_, err := g.run("branch", flag, name)
|
|
return err
|
|
}
|
|
|
|
// Rev returns the commit hash for the given ref.
|
|
func (g *Git) Rev(ref string) (string, error) {
|
|
return g.run("rev-parse", ref)
|
|
}
|
|
|
|
// IsAncestor checks if ancestor is an ancestor of descendant.
|
|
func (g *Git) IsAncestor(ancestor, descendant string) (bool, error) {
|
|
_, err := g.run("merge-base", "--is-ancestor", ancestor, descendant)
|
|
if err != nil {
|
|
// Exit code 1 means not an ancestor, not an error
|
|
if strings.Contains(err.Error(), "exit status 1") {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|