ghorg/git/git_test.go
Blair Hamilton 70f9fb45b8
Add sync feature with comprehensive testing and documentation
- Implement SyncDefaultBranch function with 4 safety checks
- Add 8 git helper functions (GetRemoteURL, HasLocalChanges, etc.)
- Add 640 lines of unit tests for git helpers (git_test.go)
- Add 2074 lines of sync tests with 45+ scenarios (sync_test.go)
- Integrate GHORG_SYNC_DEFAULT_BRANCH environment variable
- Add 8 testing targets to Makefile
- Update README.md with comprehensive sync feature documentation
- Update sample-conf.yaml with detailed configuration comments
- Add test coverage verification document

Safety checks implemented:
- Skips sync if uncommitted local changes
- Skips sync if unpushed commits
- Skips sync if commits not on default branch
- Skips sync if default branch diverged from HEAD

Test coverage: 51.6% overall, 76-100% on new functions
2025-12-08 11:14:28 -05:00

715 lines
19 KiB
Go

package git
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/gabrie30/ghorg/scm"
)
// createTestGitRepo creates a test git repository with a single commit
func createTestGitRepo(t *testing.T) (string, func()) {
t.Helper()
tempDir, err := os.MkdirTemp("", "ghorg-git-test-")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
cleanup := func() {
os.RemoveAll(tempDir)
}
// Initialize git repo
cmd := exec.Command("git", "init")
cmd.Dir = tempDir
if err := cmd.Run(); err != nil {
cleanup()
t.Fatalf("Failed to initialize git repo: %v", err)
}
// Configure git
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = tempDir
if err := cmd.Run(); err != nil {
cleanup()
t.Fatalf("Failed to configure git user.email: %v", err)
}
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = tempDir
if err := cmd.Run(); err != nil {
cleanup()
t.Fatalf("Failed to configure git user.name: %v", err)
}
// Create initial commit
testFile := filepath.Join(tempDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
cleanup()
t.Fatalf("Failed to create test file: %v", err)
}
cmd = exec.Command("git", "add", "test.txt")
cmd.Dir = tempDir
if err := cmd.Run(); err != nil {
cleanup()
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "Initial commit")
cmd.Dir = tempDir
if err := cmd.Run(); err != nil {
cleanup()
t.Fatalf("Failed to create initial commit: %v", err)
}
// Rename to main branch
cmd = exec.Command("git", "branch", "-M", "main")
cmd.Dir = tempDir
if err := cmd.Run(); err != nil {
cleanup()
t.Fatalf("Failed to rename branch to main: %v", err)
}
return tempDir, cleanup
}
func TestGetRemoteURL(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("No remote configured", func(t *testing.T) {
_, err := client.GetRemoteURL(repo, "origin")
if err == nil {
t.Error("Expected error when no remote is configured")
}
})
t.Run("Remote exists", func(t *testing.T) {
// Add a remote
expectedURL := "https://github.com/test/repo.git"
cmd := exec.Command("git", "remote", "add", "origin", expectedURL)
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add remote: %v", err)
}
url, err := client.GetRemoteURL(repo, "origin")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Git may return URL in different formats (https or ssh)
// Just verify we got a non-empty URL
if url == "" {
t.Error("Expected non-empty URL")
}
// Verify it contains the repo identifier
if !strings.Contains(url, "test/repo") && !strings.Contains(url, "test:repo") {
t.Errorf("Expected URL to contain 'test/repo' or 'test:repo', got '%s'", url)
}
})
t.Run("Non-existent remote", func(t *testing.T) {
_, err := client.GetRemoteURL(repo, "nonexistent")
if err == nil {
t.Error("Expected error for non-existent remote")
}
})
}
func TestHasLocalChanges(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("Clean working directory", func(t *testing.T) {
hasChanges, err := client.HasLocalChanges(repo)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if hasChanges {
t.Error("Expected no local changes in clean working directory")
}
})
t.Run("Modified file", func(t *testing.T) {
// Modify file
testFile := filepath.Join(repoPath, "test.txt")
if err := os.WriteFile(testFile, []byte("modified content"), 0644); err != nil {
t.Fatalf("Failed to modify file: %v", err)
}
hasChanges, err := client.HasLocalChanges(repo)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !hasChanges {
t.Error("Expected local changes after modifying file")
}
// Clean up
cmd := exec.Command("git", "checkout", "test.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to restore file: %v", err)
}
})
t.Run("Untracked file", func(t *testing.T) {
// Create untracked file
newFile := filepath.Join(repoPath, "untracked.txt")
if err := os.WriteFile(newFile, []byte("untracked"), 0644); err != nil {
t.Fatalf("Failed to create untracked file: %v", err)
}
hasChanges, err := client.HasLocalChanges(repo)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !hasChanges {
t.Error("Expected local changes with untracked file")
}
// Clean up
os.Remove(newFile)
})
}
func TestHasUnpushedCommits(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("No upstream configured", func(t *testing.T) {
_, err := client.HasUnpushedCommits(repo)
if err == nil {
t.Error("Expected error when no upstream is configured")
}
})
t.Run("With upstream and no unpushed commits", func(t *testing.T) {
// Create bare remote
bareDir, err := os.MkdirTemp("", "ghorg-bare-")
if err != nil {
t.Fatalf("Failed to create bare directory: %v", err)
}
defer os.RemoveAll(bareDir)
cmd := exec.Command("git", "init", "--bare")
cmd.Dir = bareDir
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create bare repo: %v", err)
}
// Add remote and push
cmd = exec.Command("git", "remote", "add", "origin", bareDir)
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add remote: %v", err)
}
cmd = exec.Command("git", "push", "-u", "origin", "main")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to push: %v", err)
}
hasUnpushed, err := client.HasUnpushedCommits(repo)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if hasUnpushed {
t.Error("Expected no unpushed commits after push")
}
})
t.Run("With unpushed commits", func(t *testing.T) {
// Create a new commit
newFile := filepath.Join(repoPath, "new.txt")
if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil {
t.Fatalf("Failed to create new file: %v", err)
}
cmd := exec.Command("git", "add", "new.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "New commit")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create commit: %v", err)
}
hasUnpushed, err := client.HasUnpushedCommits(repo)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !hasUnpushed {
t.Error("Expected unpushed commits after local commit")
}
})
}
func TestGetCurrentBranch(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("Get main branch", func(t *testing.T) {
branch, err := client.GetCurrentBranch(repo)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if branch != "main" {
t.Errorf("Expected branch 'main', got '%s'", branch)
}
})
t.Run("Get feature branch", func(t *testing.T) {
// Create and checkout feature branch
cmd := exec.Command("git", "checkout", "-b", "feature")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create feature branch: %v", err)
}
branch, err := client.GetCurrentBranch(repo)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if branch != "feature" {
t.Errorf("Expected branch 'feature', got '%s'", branch)
}
// Switch back to main
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to checkout main: %v", err)
}
})
}
func TestHasCommitsNotOnDefaultBranch(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("Main branch has no extra commits", func(t *testing.T) {
hasCommits, err := client.HasCommitsNotOnDefaultBranch(repo, "main")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if hasCommits {
t.Error("Expected no commits not on default branch")
}
})
t.Run("Feature branch with commits", func(t *testing.T) {
// Create feature branch
cmd := exec.Command("git", "checkout", "-b", "feature")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create feature branch: %v", err)
}
// Add commit on feature branch
newFile := filepath.Join(repoPath, "feature.txt")
if err := os.WriteFile(newFile, []byte("feature content"), 0644); err != nil {
t.Fatalf("Failed to create feature file: %v", err)
}
cmd = exec.Command("git", "add", "feature.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "Feature commit")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create commit: %v", err)
}
hasCommits, err := client.HasCommitsNotOnDefaultBranch(repo, "feature")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !hasCommits {
t.Error("Expected commits not on default branch")
}
// Switch back to main
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to checkout main: %v", err)
}
})
}
func TestIsDefaultBranchBehindHead(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("Default branch is current", func(t *testing.T) {
isBehind, err := client.IsDefaultBranchBehindHead(repo, "main")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !isBehind {
t.Error("Expected default branch to be ancestor of itself (true)")
}
})
t.Run("Default branch behind feature branch", func(t *testing.T) {
// Create feature branch with commits
cmd := exec.Command("git", "checkout", "-b", "feature")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create feature branch: %v", err)
}
// Add commit
newFile := filepath.Join(repoPath, "feature.txt")
if err := os.WriteFile(newFile, []byte("feature content"), 0644); err != nil {
t.Fatalf("Failed to create feature file: %v", err)
}
cmd = exec.Command("git", "add", "feature.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "Feature commit")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create commit: %v", err)
}
isBehind, err := client.IsDefaultBranchBehindHead(repo, "feature")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !isBehind {
t.Error("Expected default branch to be behind feature branch")
}
// Switch back to main
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to checkout main: %v", err)
}
})
t.Run("Divergent branches", func(t *testing.T) {
// Create divergent branch
cmd := exec.Command("git", "checkout", "-b", "divergent")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create divergent branch: %v", err)
}
// Add commit on divergent
file1 := filepath.Join(repoPath, "divergent.txt")
if err := os.WriteFile(file1, []byte("divergent content"), 0644); err != nil {
t.Fatalf("Failed to create file: %v", err)
}
cmd = exec.Command("git", "add", "divergent.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "Divergent commit")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create commit: %v", err)
}
// Switch to main and add different commit
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to checkout main: %v", err)
}
file2 := filepath.Join(repoPath, "main.txt")
if err := os.WriteFile(file2, []byte("main content"), 0644); err != nil {
t.Fatalf("Failed to create file: %v", err)
}
cmd = exec.Command("git", "add", "main.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "Main commit")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create commit: %v", err)
}
isBehind, err := client.IsDefaultBranchBehindHead(repo, "divergent")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if isBehind {
t.Error("Expected default branch NOT to be behind divergent branch")
}
})
}
func TestMergeIntoDefaultBranch(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("Fast-forward merge", func(t *testing.T) {
// Create feature branch
cmd := exec.Command("git", "checkout", "-b", "feature")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create feature branch: %v", err)
}
// Add commit
newFile := filepath.Join(repoPath, "feature.txt")
if err := os.WriteFile(newFile, []byte("feature content"), 0644); err != nil {
t.Fatalf("Failed to create feature file: %v", err)
}
cmd = exec.Command("git", "add", "feature.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "Feature commit")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create commit: %v", err)
}
// Merge into main
err := client.MergeIntoDefaultBranch(repo, "feature")
if err != nil {
t.Errorf("Unexpected error during merge: %v", err)
}
// Verify we're on main
currentBranch, err := client.GetCurrentBranch(repo)
if err != nil {
t.Errorf("Failed to get current branch: %v", err)
}
if currentBranch != "main" {
t.Errorf("Expected to be on main branch, got '%s'", currentBranch)
}
// Verify file exists on main
if _, err := os.Stat(newFile); os.IsNotExist(err) {
t.Error("Expected merged file to exist on main branch")
}
})
t.Run("Merge non-existent branch fails", func(t *testing.T) {
err := client.MergeIntoDefaultBranch(repo, "nonexistent")
if err == nil {
t.Error("Expected error when merging non-existent branch")
}
})
}
func TestUpdateRef(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
t.Run("Update ref to HEAD", func(t *testing.T) {
// Create a new branch
cmd := exec.Command("git", "branch", "test-branch")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create test branch: %v", err)
}
// Add a commit on main
newFile := filepath.Join(repoPath, "update-ref.txt")
if err := os.WriteFile(newFile, []byte("content"), 0644); err != nil {
t.Fatalf("Failed to create file: %v", err)
}
cmd = exec.Command("git", "add", "update-ref.txt")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to add file: %v", err)
}
cmd = exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", "Update ref commit")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create commit: %v", err)
}
// Update test-branch ref to point to main
err := client.UpdateRef(repo, "refs/heads/test-branch", "refs/heads/main")
if err != nil {
t.Errorf("Unexpected error updating ref: %v", err)
}
// Verify test-branch now points to same commit as main
cmd = exec.Command("git", "rev-parse", "main")
cmd.Dir = repoPath
mainSHA, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to get main SHA: %v", err)
}
cmd = exec.Command("git", "rev-parse", "test-branch")
cmd.Dir = repoPath
testSHA, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to get test-branch SHA: %v", err)
}
if string(mainSHA) != string(testSHA) {
t.Error("Expected test-branch to point to same commit as main after UpdateRef")
}
})
t.Run("Update ref with invalid commitRef fails", func(t *testing.T) {
err := client.UpdateRef(repo, "refs/heads/test", "nonexistent-ref")
if err == nil {
t.Error("Expected error when updating ref with invalid commitRef")
}
})
}
// TestErrorHandlingEdgeCases tests additional error handling scenarios
func TestErrorHandlingEdgeCases(t *testing.T) {
repoPath, cleanup := createTestGitRepo(t)
defer cleanup()
client := NewGit()
t.Run("GetRemoteURL with invalid repo path", func(t *testing.T) {
repo := scm.Repo{
HostPath: "/nonexistent/path",
CloneBranch: "main",
}
_, err := client.GetRemoteURL(repo, "origin")
if err == nil {
t.Error("Expected error with invalid repo path")
}
})
t.Run("GetCurrentBranch with invalid repo path", func(t *testing.T) {
repo := scm.Repo{
HostPath: "/nonexistent/path",
CloneBranch: "main",
}
_, err := client.GetCurrentBranch(repo)
if err == nil {
t.Error("Expected error with invalid repo path")
}
})
t.Run("HasCommitsNotOnDefaultBranch with invalid branch", func(t *testing.T) {
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
_, err := client.HasCommitsNotOnDefaultBranch(repo, "nonexistent-branch")
if err == nil {
t.Error("Expected error with nonexistent branch")
}
})
t.Run("IsDefaultBranchBehindHead with invalid branch", func(t *testing.T) {
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
_, err := client.IsDefaultBranchBehindHead(repo, "nonexistent-branch")
if err == nil {
t.Error("Expected error with nonexistent branch")
}
})
t.Run("MergeIntoDefaultBranch with invalid default branch", func(t *testing.T) {
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "nonexistent-default",
}
err := client.MergeIntoDefaultBranch(repo, "main")
if err == nil {
t.Error("Expected error when checking out nonexistent default branch")
}
})
t.Run("UpdateRef with invalid ref name", func(t *testing.T) {
repo := scm.Repo{
HostPath: repoPath,
CloneBranch: "main",
}
err := client.UpdateRef(repo, "invalid..ref", "HEAD")
if err == nil {
t.Error("Expected error with invalid ref name")
}
})
}