diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 00000000..9d33db0e --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,248 @@ +// 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 +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 00000000..64d772f0 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,188 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + // Initialize repo + cmd := exec.Command("git", "init") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git init: %v", err) + } + + // Configure user for commits + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = dir + cmd.Run() + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + cmd.Run() + + // Create initial commit + testFile := filepath.Join(dir, "README.md") + if err := os.WriteFile(testFile, []byte("# Test\n"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + cmd.Run() + cmd = exec.Command("git", "commit", "-m", "initial") + cmd.Dir = dir + cmd.Run() + + return dir +} + +func TestCurrentBranch(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + + branch, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + + // Modern git uses "main", older uses "master" + if branch != "main" && branch != "master" { + t.Errorf("branch = %q, want main or master", branch) + } +} + +func TestStatus(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + + // Should be clean initially + status, err := g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if !status.Clean { + t.Error("expected clean status") + } + + // Add an untracked file + testFile := filepath.Join(dir, "new.txt") + if err := os.WriteFile(testFile, []byte("new"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + status, err = g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if status.Clean { + t.Error("expected dirty status") + } + if len(status.Untracked) != 1 { + t.Errorf("untracked = %d, want 1", len(status.Untracked)) + } +} + +func TestAddAndCommit(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + + // Create a new file + testFile := filepath.Join(dir, "new.txt") + if err := os.WriteFile(testFile, []byte("new content"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + // Add and commit + if err := g.Add("new.txt"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit("add new file"); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Should be clean + status, err := g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if !status.Clean { + t.Error("expected clean after commit") + } +} + +func TestHasUncommittedChanges(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + + has, err := g.HasUncommittedChanges() + if err != nil { + t.Fatalf("HasUncommittedChanges: %v", err) + } + if has { + t.Error("expected no changes initially") + } + + // Modify a file + testFile := filepath.Join(dir, "README.md") + if err := os.WriteFile(testFile, []byte("modified"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + has, err = g.HasUncommittedChanges() + if err != nil { + t.Fatalf("HasUncommittedChanges: %v", err) + } + if !has { + t.Error("expected changes after modify") + } +} + +func TestCheckout(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + + // Create a new branch + if err := g.CreateBranch("feature"); err != nil { + t.Fatalf("CreateBranch: %v", err) + } + + // Checkout the new branch + if err := g.Checkout("feature"); err != nil { + t.Fatalf("Checkout: %v", err) + } + + branch, _ := g.CurrentBranch() + if branch != "feature" { + t.Errorf("branch = %q, want feature", branch) + } +} + +func TestNotARepo(t *testing.T) { + dir := t.TempDir() // Empty dir, not a git repo + g := NewGit(dir) + + _, err := g.CurrentBranch() + if err != ErrNotARepo { + t.Errorf("expected ErrNotARepo, got %v", err) + } +} + +func TestRev(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + + hash, err := g.Rev("HEAD") + if err != nil { + t.Fatalf("Rev: %v", err) + } + + // Should be a 40-char hex string + if len(hash) != 40 { + t.Errorf("hash length = %d, want 40", len(hash)) + } +}