feat: add git wrapper package for subprocess operations

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>
This commit is contained in:
Steve Yegge
2025-12-16 13:27:03 -08:00
parent e16931b304
commit f46a1e6eab
2 changed files with 436 additions and 0 deletions

248
internal/git/git.go Normal file
View File

@@ -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
}

188
internal/git/git_test.go Normal file
View File

@@ -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))
}
}