diff --git a/internal/git/git.go b/internal/git/git.go index fcd9db3a..68123dd3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -91,6 +91,12 @@ func (g *Git) Fetch(remote string) error { return err } +// FetchBranch fetches a specific branch from the remote. +func (g *Git) FetchBranch(remote, branch string) error { + _, err := g.run("fetch", remote, branch) + return err +} + // Pull pulls from the remote branch. func (g *Git) Pull(remote, branch string) error { _, err := g.run("pull", remote, branch) @@ -207,6 +213,95 @@ func (g *Git) AbortMerge() error { return err } +// CheckConflicts performs a test merge to check if source can be merged into target +// without conflicts. Returns a list of conflicting files, or empty slice if clean. +// The merge is always aborted after checking - no actual changes are made. +// +// The caller must ensure the working directory is clean before calling this. +// After return, the working directory is restored to the target branch. +func (g *Git) CheckConflicts(source, target string) ([]string, error) { + // Checkout the target branch + if err := g.Checkout(target); err != nil { + return nil, fmt.Errorf("checkout target %s: %w", target, err) + } + + // Attempt test merge with --no-commit --no-ff + // We need to capture both stdout and stderr to detect conflicts + _, mergeErr := g.runMergeCheck("merge", "--no-commit", "--no-ff", source) + + if mergeErr != nil { + // Check if there are unmerged files (indicates conflict) + conflicts, err := g.getConflictingFiles() + if err == nil && len(conflicts) > 0 { + // Abort the test merge + g.AbortMerge() + return conflicts, nil + } + + // Check if it's a conflict error from wrapper + if errors.Is(mergeErr, ErrMergeConflict) { + g.AbortMerge() + return conflicts, nil + } + + // Some other merge error + g.AbortMerge() + return nil, mergeErr + } + + // Merge succeeded (no conflicts) - abort the test merge + // Use reset since --abort won't work on successful merge + g.run("reset", "--hard", "HEAD") + return nil, nil +} + +// runMergeCheck runs a git merge command and returns error info from both stdout and stderr. +// This is needed because git merge outputs CONFLICT info to stdout. +func (g *Git) runMergeCheck(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 { + // Check stdout for CONFLICT message (git sends it there) + stdoutStr := stdout.String() + if strings.Contains(stdoutStr, "CONFLICT") { + return "", ErrMergeConflict + } + // Fall back to stderr check + return "", g.wrapError(err, stderr.String(), args) + } + + return strings.TrimSpace(stdout.String()), nil +} + +// getConflictingFiles returns the list of files with merge conflicts. +func (g *Git) getConflictingFiles() ([]string, error) { + // git diff --name-only --diff-filter=U shows unmerged files + out, err := g.run("diff", "--name-only", "--diff-filter=U") + if err != nil { + return nil, err + } + + if out == "" { + return nil, nil + } + + files := strings.Split(out, "\n") + // Filter out empty strings + var result []string + for _, f := range files { + if f != "" { + result = append(result, f) + } + } + return result, nil +} + // AbortRebase aborts a rebase in progress. func (g *Git) AbortRebase() error { _, err := g.run("rebase", "--abort") diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 64d772f0..6db55f92 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -186,3 +186,157 @@ func TestRev(t *testing.T) { t.Errorf("hash length = %d, want 40", len(hash)) } } + +func TestFetchBranch(t *testing.T) { + // Create a "remote" repo + remoteDir := t.TempDir() + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + if err := cmd.Run(); err != nil { + t.Fatalf("git init --bare: %v", err) + } + + // Create a local repo and push to remote + localDir := initTestRepo(t) + g := NewGit(localDir) + + // Add remote + cmd = exec.Command("git", "remote", "add", "origin", remoteDir) + cmd.Dir = localDir + if err := cmd.Run(); err != nil { + t.Fatalf("git remote add: %v", err) + } + + // Push main branch + mainBranch, _ := g.CurrentBranch() + cmd = exec.Command("git", "push", "-u", "origin", mainBranch) + cmd.Dir = localDir + if err := cmd.Run(); err != nil { + t.Fatalf("git push: %v", err) + } + + // Fetch should succeed + if err := g.FetchBranch("origin", mainBranch); err != nil { + t.Errorf("FetchBranch: %v", err) + } +} + +func TestCheckConflicts_NoConflict(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + mainBranch, _ := g.CurrentBranch() + + // Create feature branch with non-conflicting change + if err := g.CreateBranch("feature"); err != nil { + t.Fatalf("CreateBranch: %v", err) + } + if err := g.Checkout("feature"); err != nil { + t.Fatalf("Checkout feature: %v", err) + } + + // Add a new file (won't conflict with main) + newFile := filepath.Join(dir, "feature.txt") + if err := os.WriteFile(newFile, []byte("feature content"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if err := g.Add("feature.txt"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit("add feature file"); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Go back to main + if err := g.Checkout(mainBranch); err != nil { + t.Fatalf("Checkout main: %v", err) + } + + // Check for conflicts - should be none + conflicts, err := g.CheckConflicts("feature", mainBranch) + if err != nil { + t.Fatalf("CheckConflicts: %v", err) + } + if len(conflicts) > 0 { + t.Errorf("expected no conflicts, got %v", conflicts) + } + + // Verify we're still on main and clean + branch, _ := g.CurrentBranch() + if branch != mainBranch { + t.Errorf("branch = %q, want %q", branch, mainBranch) + } + status, _ := g.Status() + if !status.Clean { + t.Error("expected clean working directory after CheckConflicts") + } +} + +func TestCheckConflicts_WithConflict(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + mainBranch, _ := g.CurrentBranch() + + // Create feature branch + if err := g.CreateBranch("feature"); err != nil { + t.Fatalf("CreateBranch: %v", err) + } + if err := g.Checkout("feature"); err != nil { + t.Fatalf("Checkout feature: %v", err) + } + + // Modify README.md on feature branch + readmeFile := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Feature changes\n"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if err := g.Add("README.md"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit("modify readme on feature"); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Go back to main and make conflicting change + if err := g.Checkout(mainBranch); err != nil { + t.Fatalf("Checkout main: %v", err) + } + if err := os.WriteFile(readmeFile, []byte("# Main changes\n"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if err := g.Add("README.md"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit("modify readme on main"); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Check for conflicts - should find README.md + conflicts, err := g.CheckConflicts("feature", mainBranch) + if err != nil { + t.Fatalf("CheckConflicts: %v", err) + } + if len(conflicts) == 0 { + t.Error("expected conflicts, got none") + } + + foundReadme := false + for _, f := range conflicts { + if f == "README.md" { + foundReadme = true + break + } + } + if !foundReadme { + t.Errorf("expected README.md in conflicts, got %v", conflicts) + } + + // Verify we're still on main and clean + branch, _ := g.CurrentBranch() + if branch != mainBranch { + t.Errorf("branch = %q, want %q", branch, mainBranch) + } + status, _ := g.Status() + if !status.Clean { + t.Error("expected clean working directory after CheckConflicts") + } +}