ghorg/cmd/repository_processor_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

729 lines
20 KiB
Go

package cmd
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/gabrie30/ghorg/scm"
)
// ExtendedMockGitClient extends the existing MockGitClient with additional methods needed for RepositoryProcessor
type ExtendedMockGitClient struct {
MockGitClient
shouldFailClone bool
shouldFailCheckout bool
shouldFailSetOrigin bool
shouldReturnEmptyRepo bool
preCommitCount int
postCommitCount int
}
func NewExtendedMockGit() *ExtendedMockGitClient {
return &ExtendedMockGitClient{
MockGitClient: NewMockGit(),
preCommitCount: 5,
postCommitCount: 7,
}
}
func (g *ExtendedMockGitClient) Clone(repo scm.Repo) error {
if g.shouldFailClone {
return errors.New("mock clone error")
}
return g.MockGitClient.Clone(repo)
}
func (g *ExtendedMockGitClient) Checkout(repo scm.Repo) error {
if g.shouldFailCheckout {
return errors.New("mock checkout error")
}
if g.shouldReturnEmptyRepo {
return errors.New("Cannot checkout any specific branch in an empty repository")
}
return g.MockGitClient.Checkout(repo)
}
func (g *ExtendedMockGitClient) SetOrigin(repo scm.Repo) error {
if g.shouldFailSetOrigin {
return errors.New("mock set origin error")
}
return g.MockGitClient.SetOrigin(repo)
}
func (g *ExtendedMockGitClient) RepoCommitCount(repo scm.Repo) (int, error) {
// First call returns pre-pull count, second call returns post-pull count
if repo.Commits.CountPrePull == 0 {
return g.preCommitCount, nil
}
return g.postCommitCount, nil
}
func (g *ExtendedMockGitClient) SyncDefaultBranch(repo scm.Repo) error {
return nil
}
func TestRepositoryProcessor_NewRepositoryProcessor(t *testing.T) {
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
if processor == nil {
t.Fatal("Expected processor to be created")
}
if processor.git != mockGit {
t.Error("Expected git client to be set correctly")
}
if processor.stats == nil {
t.Error("Expected stats to be initialized")
}
if processor.mutex == nil {
t.Error("Expected mutex to be initialized")
}
}
func TestRepositoryProcessor_ProcessRepository_NewRepository(t *testing.T) {
defer UnsetEnv("GHORG_")()
// Set up temporary directory
dir, err := os.MkdirTemp("", "ghorg_test_process_new_repo")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
stats := processor.GetStats()
if stats.CloneCount != 1 {
t.Errorf("Expected clone count to be 1, got %d", stats.CloneCount)
}
if stats.PulledCount != 0 {
t.Errorf("Expected pulled count to be 0, got %d", stats.PulledCount)
}
if len(stats.CloneErrors) != 0 {
t.Errorf("Expected no clone errors, got %v", stats.CloneErrors)
}
}
func TestRepositoryProcessor_ProcessRepository_ExistingRepository(t *testing.T) {
defer UnsetEnv("GHORG_")()
// Set up temporary directory with existing repo
dir, err := os.MkdirTemp("", "ghorg_test_process_existing_repo")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
// Create existing repo directory
repoDir := filepath.Join(dir, "test-repo")
err = os.MkdirAll(repoDir, 0755)
if err != nil {
t.Fatal(err)
}
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
HostPath: repoDir,
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
stats := processor.GetStats()
if stats.CloneCount != 0 {
t.Errorf("Expected clone count to be 0, got %d", stats.CloneCount)
}
if stats.PulledCount != 1 {
t.Errorf("Expected pulled count to be 1, got %d", stats.PulledCount)
}
// Check that commit diff was calculated
if stats.NewCommits != (mockGit.postCommitCount - mockGit.preCommitCount) {
t.Errorf("Expected new commits to be %d, got %d",
mockGit.postCommitCount-mockGit.preCommitCount, stats.NewCommits)
}
// Verify that CountDiff was properly calculated on the repo
if repo.Commits.CountDiff != (mockGit.postCommitCount - mockGit.preCommitCount) {
t.Errorf("Expected repo CountDiff to be %d, got %d",
mockGit.postCommitCount-mockGit.preCommitCount, repo.Commits.CountDiff)
}
}
func TestRepositoryProcessor_ProcessRepository_CloneError(t *testing.T) {
defer UnsetEnv("GHORG_")()
dir, err := os.MkdirTemp("", "ghorg_test_process_clone_error")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
mockGit := NewExtendedMockGit()
mockGit.shouldFailClone = true
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
stats := processor.GetStats()
if stats.CloneCount != 0 {
t.Errorf("Expected clone count to be 0, got %d", stats.CloneCount)
}
if len(stats.CloneErrors) != 1 {
t.Errorf("Expected 1 clone error, got %d", len(stats.CloneErrors))
}
if stats.CloneErrors[0] == "" {
t.Error("Expected error message to be set")
}
}
func TestRepositoryProcessor_ProcessRepository_WikiHandling(t *testing.T) {
defer UnsetEnv("GHORG_")()
dir, err := os.MkdirTemp("", "ghorg_test_process_wiki")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
mockGit := NewExtendedMockGit()
mockGit.shouldFailClone = true // Simulate wiki with no content
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo.wiki",
CloneBranch: "main",
IsWiki: true,
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo.wiki", 0)
stats := processor.GetStats()
if len(stats.CloneInfos) != 1 {
t.Errorf("Expected 1 clone info message, got %d", len(stats.CloneInfos))
}
if len(stats.CloneErrors) != 0 {
t.Errorf("Expected no clone errors for wiki, got %d", len(stats.CloneErrors))
}
}
func TestRepositoryProcessor_ProcessRepository_BackupMode(t *testing.T) {
defer UnsetEnv("GHORG_")()
os.Setenv("GHORG_BACKUP", "true")
// Set up temporary directory with existing repo
dir, err := os.MkdirTemp("", "ghorg_test_backup_mode")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
// Create existing repo directory
repoDir := filepath.Join(dir, "test-repo")
err = os.MkdirAll(repoDir, 0755)
if err != nil {
t.Fatal(err)
}
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
HostPath: repoDir,
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
stats := processor.GetStats()
if stats.UpdateRemoteCount != 1 {
t.Errorf("Expected update remote count to be 1, got %d", stats.UpdateRemoteCount)
}
}
func TestRepositoryProcessor_ProcessRepository_NoCleanMode(t *testing.T) {
defer UnsetEnv("GHORG_")()
os.Setenv("GHORG_NO_CLEAN", "true")
// Set up temporary directory with existing repo
dir, err := os.MkdirTemp("", "ghorg_test_no_clean_mode")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
// Create existing repo directory
repoDir := filepath.Join(dir, "test-repo")
err = os.MkdirAll(repoDir, 0755)
if err != nil {
t.Fatal(err)
}
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
HostPath: repoDir,
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
stats := processor.GetStats()
// In no-clean mode, we still increment pulled count
if stats.PulledCount != 1 {
t.Errorf("Expected pulled count to be 1, got %d", stats.PulledCount)
}
}
func TestRepositoryProcessor_ProcessRepository_NoCleanModeWithFetchAllDisabled(t *testing.T) {
defer UnsetEnv("GHORG_")()
os.Setenv("GHORG_NO_CLEAN", "true")
os.Setenv("GHORG_FETCH_ALL", "false")
// Set up temporary directory with existing repo
dir, err := os.MkdirTemp("", "ghorg_test_no_clean_fetch_all_disabled")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
// Create existing repo directory
repoDir := filepath.Join(dir, "test-repo")
err = os.MkdirAll(repoDir, 0755)
if err != nil {
t.Fatal(err)
}
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
HostPath: repoDir,
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
stats := processor.GetStats()
// In no-clean mode with fetch-all disabled, we should still process successfully
if stats.PulledCount != 1 {
t.Errorf("Expected pulled count to be 1, got %d", stats.PulledCount)
}
// Should not have any errors since fetch-all is skipped when disabled
if len(stats.CloneErrors) != 0 {
t.Errorf("Expected no errors when FETCH_ALL is disabled, got %d errors: %v", len(stats.CloneErrors), stats.CloneErrors)
}
}
func TestRepositoryProcessor_ProcessRepository_NoCleanModeWithFetchAllEnabled(t *testing.T) {
defer UnsetEnv("GHORG_")()
os.Setenv("GHORG_NO_CLEAN", "true")
os.Setenv("GHORG_FETCH_ALL", "true")
// Set up temporary directory with existing repo
dir, err := os.MkdirTemp("", "ghorg_test_no_clean_fetch_all_enabled")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
// Create existing repo directory
repoDir := filepath.Join(dir, "test-repo")
err = os.MkdirAll(repoDir, 0755)
if err != nil {
t.Fatal(err)
}
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
HostPath: repoDir,
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
stats := processor.GetStats()
// In no-clean mode with fetch-all enabled, we should still process successfully
if stats.PulledCount != 1 {
t.Errorf("Expected pulled count to be 1, got %d", stats.PulledCount)
}
// Should not have any errors since fetch-all is enabled and mocked
if len(stats.CloneErrors) != 0 {
t.Errorf("Expected no errors when FETCH_ALL is enabled, got %d errors: %v", len(stats.CloneErrors), stats.CloneErrors)
}
}
func TestRepositoryProcessor_ProcessRepository_NameCollisions(t *testing.T) {
defer UnsetEnv("GHORG_")()
dir, err := os.MkdirTemp("", "ghorg_test_name_collisions")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
repo := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
Path: "group/subgroup/test-repo",
}
repoNameWithCollisions := map[string]bool{
"test-repo": true,
}
processor.ProcessRepository(&repo, repoNameWithCollisions, true, "test-repo", 1)
// Check that the repo was processed despite collisions
stats := processor.GetStats()
if stats.CloneCount != 1 {
t.Errorf("Expected clone count to be 1, got %d", stats.CloneCount)
}
// The host path should be modified due to collision handling
expectedPath := filepath.Join(outputDirAbsolutePath, "group_subgroup_test-repo")
if repo.HostPath != expectedPath {
t.Errorf("Expected host path to be modified for collisions, got %s", repo.HostPath)
}
}
func TestRepositoryProcessor_ProcessRepository_CrossPlatformPaths(t *testing.T) {
defer UnsetEnv("GHORG_")()
dir, err := os.MkdirTemp("", "ghorg_test_cross_platform")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
// Test with forward slashes (Unix-style)
repoUnix := scm.Repo{
Name: "test-repo",
URL: "https://github.com/org/test-repo",
CloneBranch: "main",
Path: "group/subgroup/test-repo",
}
// Test with backslashes (Windows-style)
repoWindows := scm.Repo{
Name: "test-repo2",
URL: "https://github.com/org/test-repo2",
CloneBranch: "main",
Path: "group\\subgroup\\test-repo2",
}
repoNameWithCollisions := map[string]bool{
"test-repo": true,
"test-repo2": true,
}
// Process Unix-style path
processor.ProcessRepository(&repoUnix, repoNameWithCollisions, true, "test-repo", 0)
expectedUnixPath := filepath.Join(outputDirAbsolutePath, "group_subgroup_test-repo")
if repoUnix.HostPath != expectedUnixPath {
t.Errorf("Expected Unix-style path to be %s, got %s", expectedUnixPath, repoUnix.HostPath)
}
// Process Windows-style path
processor.ProcessRepository(&repoWindows, repoNameWithCollisions, true, "test-repo2", 1)
expectedWindowsPath := filepath.Join(outputDirAbsolutePath, "group_subgroup_test-repo2")
if repoWindows.HostPath != expectedWindowsPath {
t.Errorf("Expected Windows-style path to be %s, got %s", expectedWindowsPath, repoWindows.HostPath)
}
stats := processor.GetStats()
if stats.CloneCount != 2 {
t.Errorf("Expected clone count to be 2, got %d", stats.CloneCount)
}
}
func TestRepositoryProcessor_ProcessRepository_GitLabSnippets(t *testing.T) {
defer UnsetEnv("GHORG_")()
dir, err := os.MkdirTemp("", "ghorg_test_gitlab_snippets")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
// Test regular snippet
repo := scm.Repo{
Name: "test-repo",
URL: "https://gitlab.com/org/test-repo",
CloneBranch: "main",
IsGitLabSnippet: true,
GitLabSnippetInfo: scm.GitLabSnippet{
Title: "My Snippet",
ID: "123",
URLOfRepo: "https://gitlab.com/org/test-repo.git",
},
}
repoNameWithCollisions := make(map[string]bool)
processor.ProcessRepository(&repo, repoNameWithCollisions, false, "test-repo", 0)
expectedPath := filepath.Join(outputDirAbsolutePath, "test-repo.snippets", "My Snippet-123")
if repo.HostPath != expectedPath {
t.Errorf("Expected host path %s, got %s", expectedPath, repo.HostPath)
}
// Test root level snippet
rootSnippetRepo := scm.Repo{
Name: "root-snippet",
URL: "https://gitlab.com/snippets/456",
CloneBranch: "main",
IsGitLabSnippet: true,
IsGitLabRootLevelSnippet: true,
GitLabSnippetInfo: scm.GitLabSnippet{
Title: "Root Snippet",
ID: "456",
},
}
processor.ProcessRepository(&rootSnippetRepo, repoNameWithCollisions, false, "root-snippet", 0)
expectedRootPath := filepath.Join(outputDirAbsolutePath, "_ghorg_root_level_snippets", "Root Snippet-456")
if rootSnippetRepo.HostPath != expectedRootPath {
t.Errorf("Expected host path %s, got %s", expectedRootPath, rootSnippetRepo.HostPath)
}
stats := processor.GetStats()
if stats.CloneCount != 2 {
t.Errorf("Expected clone count to be 2, got %d", stats.CloneCount)
}
}
func TestRepositoryProcessor_GetStats(t *testing.T) {
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
// Add some stats manually
processor.addError("test error")
processor.addInfo("test info")
stats := processor.GetStats()
if len(stats.CloneErrors) != 1 {
t.Errorf("Expected 1 clone error, got %d", len(stats.CloneErrors))
}
if stats.CloneErrors[0] != "test error" {
t.Errorf("Expected error message 'test error', got '%s'", stats.CloneErrors[0])
}
if len(stats.CloneInfos) != 1 {
t.Errorf("Expected 1 clone info, got %d", len(stats.CloneInfos))
}
if stats.CloneInfos[0] != "test info" {
t.Errorf("Expected info message 'test info', got '%s'", stats.CloneInfos[0])
}
}
func TestRepositoryProcessor_ThreadSafety(t *testing.T) {
defer UnsetEnv("GHORG_")()
dir, err := os.MkdirTemp("", "ghorg_test_thread_safety")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
outputDirAbsolutePath = dir
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
// Simulate concurrent access
numGoroutines := 10
done := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(index int) {
processor.addError("error " + string(rune(index)))
processor.addInfo("info " + string(rune(index)))
done <- true
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < numGoroutines; i++ {
<-done
}
stats := processor.GetStats()
if len(stats.CloneErrors) != numGoroutines {
t.Errorf("Expected %d clone errors, got %d", numGoroutines, len(stats.CloneErrors))
}
if len(stats.CloneInfos) != numGoroutines {
t.Errorf("Expected %d clone infos, got %d", numGoroutines, len(stats.CloneInfos))
}
}
func TestRepositoryProcessor_SetTotalDuration(t *testing.T) {
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
// Test setting timing
processor.SetTotalDuration(42)
stats := processor.GetStats()
if stats.TotalDurationSeconds != 42 {
t.Errorf("Expected total duration to be 42, got %d", stats.TotalDurationSeconds)
}
}
func TestRepositoryProcessor_SetTotalDuration_ThreadSafety(t *testing.T) {
mockGit := NewExtendedMockGit()
processor := NewRepositoryProcessor(mockGit)
// Test concurrent timing updates
numGoroutines := 10
done := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(index int) {
processor.SetTotalDuration(index * 10)
done <- true
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < numGoroutines; i++ {
<-done
}
// The final value should be one of the set values (race condition, but still valid)
stats := processor.GetStats()
validValues := make(map[int]bool)
for i := 0; i < numGoroutines; i++ {
validValues[i*10] = true
}
if !validValues[stats.TotalDurationSeconds] {
t.Errorf("Expected total duration to be one of the set values, got %d", stats.TotalDurationSeconds)
}
}
func TestCloneStats_NewStruct(t *testing.T) {
stats := CloneStats{
CloneCount: 5,
PulledCount: 3,
UpdateRemoteCount: 2,
NewCommits: 10,
UntouchedPrunes: 1,
TotalDurationSeconds: 120,
CloneInfos: []string{"info1", "info2"},
CloneErrors: []string{"error1"},
}
// Verify all fields are properly set
if stats.CloneCount != 5 {
t.Errorf("Expected CloneCount to be 5, got %d", stats.CloneCount)
}
if stats.PulledCount != 3 {
t.Errorf("Expected PulledCount to be 3, got %d", stats.PulledCount)
}
if stats.UpdateRemoteCount != 2 {
t.Errorf("Expected UpdateRemoteCount to be 2, got %d", stats.UpdateRemoteCount)
}
if stats.NewCommits != 10 {
t.Errorf("Expected NewCommits to be 10, got %d", stats.NewCommits)
}
if stats.UntouchedPrunes != 1 {
t.Errorf("Expected UntouchedPrunes to be 1, got %d", stats.UntouchedPrunes)
}
if stats.TotalDurationSeconds != 120 {
t.Errorf("Expected TotalDurationSeconds to be 120, got %d", stats.TotalDurationSeconds)
}
if len(stats.CloneInfos) != 2 {
t.Errorf("Expected 2 CloneInfos, got %d", len(stats.CloneInfos))
}
if len(stats.CloneErrors) != 1 {
t.Errorf("Expected 1 CloneError, got %d", len(stats.CloneErrors))
}
}