diff --git a/internal/git/git.go b/internal/git/git.go index 6e44f66..90a1d04 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,10 +1,157 @@ package git import ( + "fmt" "os/exec" + "path/filepath" ) -// GetStatus executes `git status` and returns its output as a string. +// ExecCommand is a variable that holds the exec.Command function +// This allows it to be mocked in tests +var ExecCommand = exec.Command + +type GitCommands struct{} + +func NewGitCommands() *GitCommands { + return &GitCommands{} +} + +func (g *GitCommands) InitRepository(path string) error { + if path == "" { + path = "." + } + + cmd := ExecCommand("git", "init", path) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to initialize repository: %v\nOutput: %s", err, output) + } + + absPath, _ := filepath.Abs(path) + fmt.Printf("Initialized empty Git repository in %s\n", absPath) + return nil +} + +func (g *GitCommands) CloneRepository(repoURL, directory string) error { + if repoURL == "" { + return fmt.Errorf("repository URL is required") + } + + var cmd *exec.Cmd + if directory != "" { + cmd = exec.Command("git", "clone", repoURL, directory) + } else { + cmd = exec.Command("git", "clone", repoURL) + } + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to clone repository: %v\nOutput: %s", err, output) + } + + fmt.Printf("Successfully cloned repository: %s\n", repoURL) + return nil +} + +func (g *GitCommands) ShowStatus() error { + cmd := exec.Command("git", "status", "--porcelain") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get status: %v", err) + } + + if len(output) == 0 { + fmt.Println("Working directory clean") + return nil + } + + cmd = exec.Command("git", "status") + output, err = cmd.Output() + if err != nil { + return fmt.Errorf("failed to get detailed status: %v", err) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) ShowLog(options LogOptions) error { + args := []string{"log"} + + if options.Oneline { + args = append(args, "--oneline") + } + if options.Graph { + args = append(args, "--graph") + } + if options.MaxCount > 0 { + args = append(args, fmt.Sprintf("-%d", options.MaxCount)) + } + + cmd := exec.Command("git", args...) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get log: %v", err) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) ShowDiff(options DiffOptions) error { + args := []string{"diff"} + + if options.Cached { + args = append(args, "--cached") + } + if options.Stat { + args = append(args, "--stat") + } + if options.Commit1 != "" { + args = append(args, options.Commit1) + } + if options.Commit2 != "" { + args = append(args, options.Commit2) + } + + cmd := exec.Command("git", args...) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get diff: %v", err) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) ShowCommit(commitHash string) error { + if commitHash == "" { + commitHash = "HEAD" + } + + cmd := exec.Command("git", "show", commitHash) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to show commit: %v", err) + } + + fmt.Print(string(output)) + return nil +} + +type LogOptions struct { + Oneline bool + Graph bool + MaxCount int +} + +type DiffOptions struct { + Commit1 string + Commit2 string + Cached bool + Stat bool +} + func GetStatus() (string, error) { cmd := exec.Command("git", "status") output, err := cmd.CombinedOutput() @@ -13,3 +160,502 @@ func GetStatus() (string, error) { } return string(output), nil } + +func (g *GitCommands) AddFiles(paths []string) error { + if len(paths) == 0 { + paths = []string{"."} + } + + args := append([]string{"add"}, paths...) + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to add files: %v\nOutput: %s", err, output) + } + + fmt.Printf("Successfully added files to staging area\n") + return nil +} + +func (g *GitCommands) ResetFiles(paths []string) error { + if len(paths) == 0 { + return fmt.Errorf("at least one file path is required") + } + + args := append([]string{"reset"}, paths...) + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to unstage files: %v\nOutput: %s", err, output) + } + + fmt.Printf("Successfully unstaged files\n") + return nil +} + +type CommitOptions struct { + Message string + Amend bool +} + +func (g *GitCommands) Commit(options CommitOptions) error { + if options.Message == "" && !options.Amend { + return fmt.Errorf("commit message is required unless amending") + } + + args := []string{"commit"} + + if options.Amend { + args = append(args, "--amend") + } + + if options.Message != "" { + args = append(args, "-m", options.Message) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to commit changes: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +type BranchOptions struct { + Create bool + Delete bool + Name string +} + +func (g *GitCommands) ManageBranch(options BranchOptions) error { + args := []string{"branch"} + + if options.Delete { + if options.Name == "" { + return fmt.Errorf("branch name is required for deletion") + } + args = append(args, "-d", options.Name) + } else if options.Create { + if options.Name == "" { + return fmt.Errorf("branch name is required for creation") + } + args = append(args, options.Name) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("branch operation failed: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) Checkout(branchName string) error { + if branchName == "" { + return fmt.Errorf("branch name is required") + } + + cmd := exec.Command("git", "checkout", branchName) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to checkout branch: %v\nOutput: %s", err, output) + } + + fmt.Printf("Switched to branch '%s'\n", branchName) + return nil +} + +func (g *GitCommands) Switch(branchName string) error { + if branchName == "" { + return fmt.Errorf("branch name is required") + } + + cmd := exec.Command("git", "switch", branchName) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to switch branch: %v\nOutput: %s", err, output) + } + + fmt.Printf("Switched to branch '%s'\n", branchName) + return nil +} + +type MergeOptions struct { + BranchName string + NoFastForward bool + Message string +} + +func (g *GitCommands) Merge(options MergeOptions) error { + if options.BranchName == "" { + return fmt.Errorf("branch name is required") + } + + args := []string{"merge"} + + if options.NoFastForward { + args = append(args, "--no-ff") + } + + if options.Message != "" { + args = append(args, "-m", options.Message) + } + + args = append(args, options.BranchName) + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to merge branch: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +type TagOptions struct { + Create bool + Delete bool + Name string + Message string + Commit string +} + +func (g *GitCommands) ManageTag(options TagOptions) error { + args := []string{"tag"} + + if options.Delete { + if options.Name == "" { + return fmt.Errorf("tag name is required for deletion") + } + args = append(args, "-d", options.Name) + } else if options.Create { + if options.Name == "" { + return fmt.Errorf("tag name is required for creation") + } + if options.Message != "" { + args = append(args, "-m", options.Message) + } + args = append(args, options.Name) + if options.Commit != "" { + args = append(args, options.Commit) + } + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("tag operation failed: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +type RemoteOptions struct { + Add bool + Remove bool + Name string + URL string + Verbose bool +} + +func (g *GitCommands) ManageRemote(options RemoteOptions) error { + args := []string{"remote"} + + if options.Verbose { + args = append(args, "-v") + } + + if options.Add { + if options.Name == "" || options.URL == "" { + return fmt.Errorf("remote name and URL are required for adding") + } + args = append(args, "add", options.Name, options.URL) + } else if options.Remove { + if options.Name == "" { + return fmt.Errorf("remote name is required for removal") + } + args = append(args, "remove", options.Name) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("remote operation failed: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) Fetch(remote string, branch string) error { + args := []string{"fetch"} + + if remote != "" { + args = append(args, remote) + } + + if branch != "" { + args = append(args, branch) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to fetch: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +type PullOptions struct { + Remote string + Branch string + Rebase bool +} + +func (g *GitCommands) Pull(options PullOptions) error { + args := []string{"pull"} + + if options.Rebase { + args = append(args, "--rebase") + } + + if options.Remote != "" { + args = append(args, options.Remote) + } + + if options.Branch != "" { + args = append(args, options.Branch) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to pull: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +type PushOptions struct { + Remote string + Branch string + Force bool + SetUpstream bool + Tags bool +} + +func (g *GitCommands) Push(options PushOptions) error { + args := []string{"push"} + + if options.Force { + args = append(args, "--force") + } + + if options.SetUpstream { + args = append(args, "--set-upstream") + } + + if options.Tags { + args = append(args, "--tags") + } + + if options.Remote != "" { + args = append(args, options.Remote) + } + + if options.Branch != "" { + args = append(args, options.Branch) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to push: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) RemoveFiles(paths []string, cached bool) error { + if len(paths) == 0 { + return fmt.Errorf("at least one file path is required") + } + + args := []string{"rm"} + + if cached { + args = append(args, "--cached") + } + + args = append(args, paths...) + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to remove files: %v\nOutput: %s", err, output) + } + + fmt.Printf("Successfully removed files\n") + return nil +} + +func (g *GitCommands) MoveFile(source, destination string) error { + if source == "" || destination == "" { + return fmt.Errorf("source and destination paths are required") + } + + cmd := exec.Command("git", "mv", source, destination) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to move file: %v\nOutput: %s", err, output) + } + + fmt.Printf("Successfully moved %s to %s\n", source, destination) + return nil +} + +type RestoreOptions struct { + Paths []string + Source string + Staged bool + WorkingDir bool +} + +func (g *GitCommands) Restore(options RestoreOptions) error { + if len(options.Paths) == 0 { + return fmt.Errorf("at least one file path is required") + } + + args := []string{"restore"} + + if options.Staged { + args = append(args, "--staged") + } + + if options.WorkingDir { + args = append(args, "--worktree") + } + + if options.Source != "" { + args = append(args, "--source", options.Source) + } + + args = append(args, options.Paths...) + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to restore files: %v\nOutput: %s", err, output) + } + + fmt.Printf("Successfully restored files\n") + return nil +} + +func (g *GitCommands) Revert(commitHash string) error { + if commitHash == "" { + return fmt.Errorf("commit hash is required") + } + + cmd := exec.Command("git", "revert", commitHash) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to revert commit: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +type StashOptions struct { + Push bool + Pop bool + Apply bool + List bool + Show bool + Drop bool + Message string + StashID string +} + +func (g *GitCommands) Stash(options StashOptions) error { + if !options.Push && !options.Pop && !options.Apply && !options.List && !options.Show && !options.Drop { + options.Push = true + } + + var args []string + + if options.Push { + args = []string{"stash", "push"} + if options.Message != "" { + args = append(args, "-m", options.Message) + } + } else if options.Pop { + args = []string{"stash", "pop"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } else if options.Apply { + args = []string{"stash", "apply"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } else if options.List { + args = []string{"stash", "list"} + } else if options.Show { + args = []string{"stash", "show"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } else if options.Drop { + args = []string{"stash", "drop"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("stash operation failed: %v\nOutput: %s", err, output) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) ListFiles() error { + cmd := exec.Command("git", "ls-files") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to list files: %v", err) + } + + fmt.Print(string(output)) + return nil +} + +func (g *GitCommands) BlameFile(filePath string) error { + if filePath == "" { + return fmt.Errorf("file path is required") + } + + cmd := exec.Command("git", "blame", filePath) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to blame file: %v", err) + } + + fmt.Print(string(output)) + return nil +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..be5e25b --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,293 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// setupTestRepo creates a new temporary directory, initializes a git repository, +// and returns a cleanup function to be deferred. +func setupTestRepo(t *testing.T) (string, func()) { + t.Helper() + tempDir, err := os.MkdirTemp("", "git-test-") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + + g := NewGitCommands() + if err := g.InitRepository(""); err != nil { + t.Fatalf("failed to initialize git repository: %v", err) + } + + // Configure git user for commits + if err := runGitConfig(tempDir); err != nil { + t.Fatalf("failed to set git config: %v", err) + } + + cleanup := func() { + if err := os.Chdir(originalDir); err != nil { + t.Fatalf("failed to change back to original directory: %v", err) + } + if err := os.RemoveAll(tempDir); err != nil { + t.Logf("failed to remove temp dir: %v", err) + } + } + + return tempDir, cleanup +} + +// createAndCommitFile creates a file with content and commits it. +func createAndCommitFile(t *testing.T, g *GitCommands, filename, content, message string) { + t.Helper() + if err := os.WriteFile(filename, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file %s: %v", filename, err) + } + if err := g.AddFiles([]string{filename}); err != nil { + t.Fatalf("failed to add file %s: %v", filename, err) + } + if err := g.Commit(CommitOptions{Message: message}); err != nil { + t.Fatalf("failed to commit file %s: %v", filename, err) + } +} + +func TestNewGitCommands(t *testing.T) { + if g := NewGitCommands(); g == nil { + t.Error("NewGitCommands() returned nil") + } +} + +func TestGitCommands_InitRepository(t *testing.T) { + tempDir, err := os.MkdirTemp("", "git-test-") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Logf("failed to remove temp dir: %v", err) + } + }() + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Fatalf("failed to change back to original directory: %v", err) + } + }() + + g := NewGitCommands() + repoPath := "test-repo" + if err := g.InitRepository(repoPath); err != nil { + t.Fatalf("InitRepository() failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + t.Errorf("expected .git directory to be created at %s", repoPath) + } +} + +func TestGitCommands_CloneRepository(t *testing.T) { + g := NewGitCommands() + err := g.CloneRepository("invalid-url", "") + if err == nil { + t.Error("CloneRepository() with invalid URL should have failed, but did not") + } + if !strings.Contains(err.Error(), "failed to clone repository") { + t.Errorf("expected clone error, got: %v", err) + } +} + +func TestGitCommands_Status(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + g := NewGitCommands() + + // Test on a clean repo + if err := g.ShowStatus(); err != nil { + t.Errorf("ShowStatus() on clean repo failed: %v", err) + } + + // Test with a new file + if err := os.WriteFile("new-file.txt", []byte("content"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + if err := g.ShowStatus(); err != nil { + t.Errorf("ShowStatus() with new file failed: %v", err) + } +} + +func TestGitCommands_Log(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + g := NewGitCommands() + createAndCommitFile(t, g, "log-test.txt", "content", "Initial commit for log test") + + if err := g.ShowLog(LogOptions{}); err != nil { + t.Errorf("ShowLog() failed: %v", err) + } +} + +func TestGitCommands_Diff(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + g := NewGitCommands() + createAndCommitFile(t, g, "diff-test.txt", "initial", "Initial commit for diff test") + + // Modify the file to create a diff + if err := os.WriteFile("diff-test.txt", []byte("modified"), 0644); err != nil { + t.Fatalf("failed to modify test file: %v", err) + } + + if err := g.ShowDiff(DiffOptions{}); err != nil { + t.Errorf("ShowDiff() failed: %v", err) + } +} + +func TestGitCommands_Commit(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + g := NewGitCommands() + + // Test empty commit message + if err := g.Commit(CommitOptions{}); err == nil { + t.Error("Commit() with empty message should fail") + } + + // Test successful commit + createAndCommitFile(t, g, "commit-test.txt", "content", "Successful commit") + + // Test amend + if err := os.WriteFile("commit-test.txt", []byte("amended content"), 0644); err != nil { + t.Fatalf("failed to amend test file: %v", err) + } + if err := g.AddFiles([]string{"commit-test.txt"}); err != nil { + t.Fatalf("failed to add amended file: %v", err) + } + if err := g.Commit(CommitOptions{Amend: true}); err != nil { + t.Errorf("Commit() with amend failed: %v", err) + } +} + +func TestGitCommands_BranchAndCheckout(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + g := NewGitCommands() + createAndCommitFile(t, g, "branch-test.txt", "content", "Initial commit for branch test") + + branchName := "feature-branch" + + // Create branch + if err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { + t.Fatalf("ManageBranch() create failed: %v", err) + } + + // Checkout branch + if err := g.Checkout(branchName); err != nil { + t.Fatalf("Checkout() failed: %v", err) + } + + // Switch back to main/master + if err := g.Switch("master"); err != nil { + t.Fatalf("Switch() failed: %v", err) + } + + // Delete branch + if err := g.ManageBranch(BranchOptions{Delete: true, Name: branchName}); err != nil { + t.Fatalf("ManageBranch() delete failed: %v", err) + } +} + +func TestGitCommands_FileOperations(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + g := NewGitCommands() + createAndCommitFile(t, g, "file-ops.txt", "content", "Initial commit for file ops") + + // Test Add + if err := os.WriteFile("new-file.txt", []byte("new"), 0644); err != nil { + t.Fatalf("failed to create new file: %v", err) + } + if err := g.AddFiles([]string{"new-file.txt"}); err != nil { + t.Errorf("AddFiles() failed: %v", err) + } + + // Test Reset + if err := g.ResetFiles([]string{"new-file.txt"}); err != nil { + t.Errorf("ResetFiles() failed: %v", err) + } + + // Test Remove + if err := g.RemoveFiles([]string{"file-ops.txt"}, false); err != nil { + t.Errorf("RemoveFiles() failed: %v", err) + } + if _, err := os.Stat("file-ops.txt"); !os.IsNotExist(err) { + t.Error("file should have been removed from working directory") + } + + // Test Move + createAndCommitFile(t, g, "source.txt", "move content", "Commit for move test") + if err := g.MoveFile("source.txt", "destination.txt"); err != nil { + t.Errorf("MoveFile() failed: %v", err) + } + if _, err := os.Stat("destination.txt"); os.IsNotExist(err) { + t.Error("destination file does not exist after move") + } +} + +func TestGitCommands_Stash(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + g := NewGitCommands() + createAndCommitFile(t, g, "stash-test.txt", "content", "Initial commit for stash test") + + // Modify file to create something to stash + if err := os.WriteFile("stash-test.txt", []byte("modified"), 0644); err != nil { + t.Fatalf("failed to modify test file: %v", err) + } + + // Stash push + if err := g.Stash(StashOptions{Push: true, Message: "test stash"}); err != nil { + t.Fatalf("Stash() push failed: %v", err) + } + + // Stash apply + if err := g.Stash(StashOptions{Apply: true}); err != nil { + t.Errorf("Stash() apply failed: %v", err) + } +} + +// Helper function to set git config for tests +func runGitConfig(dir string) error { + cmd := exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + return err + } + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = dir + return cmd.Run() +}