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:
248
internal/git/git.go
Normal file
248
internal/git/git.go
Normal 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
188
internal/git/git_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user