package ignore import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "alin.ovh/erl/repository" ) func TestNew(t *testing.T) { t.Parallel() tests := []struct { name string wd string paths []string expected *Filter }{ { name: "create with working directory only", wd: "/tmp/test", expected: &Filter{ wd: "/tmp/test", paths: nil, }, }, { name: "create with working directory and paths", wd: "/tmp/test", paths: []string{"/path/to/.gitignore", "/path/to/.ignore"}, expected: &Filter{ wd: "/tmp/test", paths: []string{"/path/to/.gitignore", "/path/to/.ignore"}, }, }, { name: "create with empty working directory", wd: "", expected: &Filter{ wd: "", paths: nil, }, }, { name: "create with no paths", wd: "/some/path", expected: &Filter{ wd: "/some/path", paths: nil, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := New(tt.wd, tt.paths...) if result.wd != tt.expected.wd { t.Errorf("expected wd %q, got %q", tt.expected.wd, result.wd) } // Constructor paths are now stored in givenPaths, not paths // paths is only populated after ReadIgnoreFiles() is called if len(result.givenPaths) != len(tt.expected.paths) { t.Errorf( "expected %d given paths, got %d", len(tt.expected.paths), len(result.givenPaths), ) return } for i, path := range result.givenPaths { if path != tt.expected.paths[i] { t.Errorf("expected givenPath[%d] %q, got %q", i, tt.expected.paths[i], path) } } // Verify other fields are initialized correctly if result.repo != nil { t.Error("expected repo to be nil for new filter") } if result.projectRoot != "" { t.Error("expected projectRoot to be empty for new filter") } if result.paths != nil { t.Error("expected paths to be nil for new filter") } if result.ignores != nil { t.Error("expected ignores to be nil for new filter") } }) } } func TestFilter_ReadIgnoreFiles(t *testing.T) { t.Parallel() projectRoot, err := filepath.Abs("../") if err != nil { t.Fatalf("failed to get project root: %v", err) } tests := []struct { name string wd string expectError bool minFiles int // minimum number of ignore files expected }{ { name: "simple case", wd: filepath.Join(projectRoot, "testcases", "ignores", "simple"), minFiles: 1, // should find at least .gitignore }, { name: "nested case with both .gitignore and .ignore", wd: filepath.Join(projectRoot, "testcases", "ignores", "nested", "subdir"), minFiles: 2, // should find both .gitignore and .ignore files }, { name: "multiple ignore files in same directory", wd: filepath.Join(projectRoot, "testcases", "ignores", "multiple"), minFiles: 2, // should find both .gitignore and .ignore }, { name: "working directory same as repo root", wd: projectRoot, minFiles: 1, // should find root .gitignore }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filter := New(tt.wd) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) if tt.expectError { if err == nil { t.Error("expected error, got nil") } return } if err != nil { t.Fatalf("ReadIgnoreFiles failed: %v", err) } // Verify filter state after successful ReadIgnoreFiles if filter.repo == nil { t.Error("expected repo to be set after ReadIgnoreFiles") } if filter.projectRoot == "" { t.Error("expected projectRoot to be set after ReadIgnoreFiles") } if len(filter.paths) < tt.minFiles { t.Errorf( "expected at least %d ignore files, got %d", tt.minFiles, len(filter.paths), ) } if len(filter.ignores) == 0 { t.Error("should have compiled some ignore objects") } if len(filter.ignores) != len(filter.paths) { t.Errorf("number of compiled ignores (%d) should match number of paths (%d)", len(filter.ignores), len(filter.paths)) } t.Logf("Found %d ignore files: %v", len(filter.paths), filter.paths) t.Logf("Compiled %d ignore objects", len(filter.ignores)) t.Logf("Project root: %s", filter.projectRoot) t.Logf("Repository type: %v", filter.repo.GetKind()) // Verify all paths exist and are files for _, path := range filter.paths { stat, err := os.Stat(path) if err != nil { t.Errorf("ignore file does not exist: %s", path) continue } if stat.IsDir() { t.Errorf("ignore path is a directory, not a file: %s", path) } } // Verify paths are within project root or its parents for _, path := range filter.paths { absPath, err := filepath.Abs(path) if err != nil { t.Errorf("failed to get absolute path for %s: %v", path, err) continue } // Path should either be within project root or be a parent directory's ignore file rel, err := filepath.Rel(filter.projectRoot, absPath) if err != nil { // If Rel fails, check if it's a parent directory parentRel, parentErr := filepath.Rel(absPath, filter.projectRoot) if parentErr != nil || filepath.IsAbs(parentRel) { t.Logf("Ignore file is in parent directory: %s", absPath) } } else if strings.HasPrefix(rel, "..") { t.Logf("Ignore file is in parent directory: %s", absPath) } } }) } } func TestFilter_ReadIgnoreFiles_withoutPresetPaths(t *testing.T) { t.Parallel() projectRoot, err := filepath.Abs("../") if err != nil { t.Fatalf("failed to get project root: %v", err) } // Test that ReadIgnoreFiles works without preset paths filter := New(projectRoot) ctx := context.Background() err = filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("ReadIgnoreFiles failed: %v", err) } // Should have found discovered paths if len(filter.paths) < 1 { t.Error("should have found some ignore files") } if len(filter.ignores) != len(filter.paths) { t.Errorf("number of compiled ignores (%d) should match number of paths (%d)", len(filter.ignores), len(filter.paths)) } } func TestFilter_ReadIgnoreFiles_multipleCalls(t *testing.T) { t.Parallel() projectRoot, err := filepath.Abs("../") if err != nil { t.Fatalf("failed to get project root: %v", err) } filter := New(filepath.Join(projectRoot, "testcases", "ignores", "simple")) ctx := context.Background() // First call err = filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("first ReadIgnoreFiles failed: %v", err) } firstPaths := make([]string, len(filter.paths)) copy(firstPaths, filter.paths) firstIgnoreCount := len(filter.ignores) // Second call should be idempotent - produce the same results err = filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("second ReadIgnoreFiles failed: %v", err) } // Verify idempotent behavior: second call should have same results as first if len(filter.paths) != len(firstPaths) { t.Errorf("ReadIgnoreFiles() is not idempotent: path count changed from %d to %d", len(firstPaths), len(filter.paths)) } if len(filter.ignores) != firstIgnoreCount { t.Errorf("ReadIgnoreFiles() is not idempotent: ignore count changed from %d to %d", firstIgnoreCount, len(filter.ignores)) } // Verify paths are exactly the same (no duplicates, same order) if len(filter.paths) == len(firstPaths) { for i, path := range filter.paths { if path != firstPaths[i] { t.Errorf("ReadIgnoreFiles() is not idempotent: path[%d] changed from %q to %q", i, firstPaths[i], path) } } } // Verify no duplicate paths exist pathCount := make(map[string]int) for _, path := range filter.paths { pathCount[path]++ } for path, count := range pathCount { if count > 1 { t.Errorf( "Duplicate path found after multiple calls: %s (appears %d times)", path, count, ) } } } func TestFilter_ReadIgnoreFiles_errors(t *testing.T) { t.Parallel() t.Run("not in a repository", func(t *testing.T) { t.Parallel() // Create temp directory that's not a git repo tmpDir, err := os.MkdirTemp("/tmp", "not-git-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) filter := New(tmpDir) ctx := context.Background() err = filter.ReadIgnoreFiles(ctx) if err == nil { t.Error("expected error when not in a repository") } if !errors.Is(err, repository.ErrNotRepo) { t.Errorf("expected repository.ErrNotRepo, got %v", err) } }) t.Run("nonexistent working directory", func(t *testing.T) { t.Parallel() nonExistentDir := "/tmp/definitely-does-not-exist-12345" filter := New(nonExistentDir) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) if err == nil { t.Error("expected error for nonexistent working directory") } }) t.Run("ignore file is directory", func(t *testing.T) { t.Parallel() // Create a git repo in temp directory tmpDir, err := os.MkdirTemp("", "git-test-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // Initialize git repo if err := runCmd(tmpDir, "git", "init"); err != nil { t.Fatalf("failed to init git repo: %v", err) } // Create .gitignore as a directory instead of a file gitignoreDir := filepath.Join(tmpDir, ".gitignore") if err := os.Mkdir(gitignoreDir, 0o755); err != nil { t.Fatalf("failed to create .gitignore directory: %v", err) } filter := New(tmpDir) ctx := context.Background() err = filter.ReadIgnoreFiles(ctx) if err == nil { t.Error("expected error when .gitignore is a directory") } if !strings.Contains(err.Error(), "directory") { t.Errorf("error should mention directory, got: %v", err) } }) } func TestFilter_getPaths_integrated(t *testing.T) { t.Parallel() // Test getPaths indirectly through ReadIgnoreFiles since it requires proper setup projectRoot, err := filepath.Abs("../") if err != nil { t.Fatalf("failed to get project root: %v", err) } tests := []struct { name string wd string expectedContains []string // file basenames that should be found }{ { name: "finds both .gitignore and .ignore", wd: filepath.Join(projectRoot, "testcases", "ignores", "multiple"), expectedContains: []string{".gitignore", ".ignore"}, }, { name: "finds nested ignore files", wd: filepath.Join( projectRoot, "testcases", "ignores", "nested", "subdir", ), expectedContains: []string{".gitignore", ".ignore"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filter := New(tt.wd) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("ReadIgnoreFiles failed: %v", err) } // Check that expected file types are found for _, expectedBasename := range tt.expectedContains { found := false for _, path := range filter.paths { if filepath.Base(path) == expectedBasename { found = true break } } if !found { t.Errorf( "expected to find file with basename %s, paths: %v", expectedBasename, filter.paths, ) } } // Verify the paths are properly ordered (should go from repo root to working directory) if len(filter.paths) > 1 { for i := 1; i < len(filter.paths); i++ { // Each subsequent path should be deeper in the directory tree prev := filter.paths[i-1] curr := filter.paths[i] // Check if current path is in same directory or subdirectory of previous prevDir := filepath.Dir(prev) currDir := filepath.Dir(curr) rel, err := filepath.Rel(prevDir, currDir) if err == nil && !strings.HasPrefix(rel, "..") { // Current is in same or subdirectory of previous - this is expected continue } else { t.Logf("Path ordering: %s -> %s (rel: %s)", prev, curr, rel) } } } }) } } func TestDefaultIgnoreFileNames(t *testing.T) { t.Parallel() name := ".ignore" if DefaultIgnoreFilename != name { t.Errorf( "expected DefaultIgnoreFileName = %q, got %q", name, DefaultIgnoreFilename, ) } } func TestFilter_ReadIgnoreFiles_edgeCases(t *testing.T) { t.Parallel() projectRoot, err := filepath.Abs("../") if err != nil { t.Fatalf("failed to get project root: %v", err) } t.Run("relative working directory", func(t *testing.T) { t.Parallel() // Test with relative path from current test directory relativeWd := "../testcases/ignores/simple" filter := New(relativeWd) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) // Note: Relative working directories may not work properly with repository detection // since filepath.Rel() may fail when trying to make relative paths relative to repo root if err != nil { t.Logf("ReadIgnoreFiles failed with relative wd (this may be expected): %v", err) // This is acceptable behavior - relative paths may not be fully supported return } // If it succeeds, verify it found files if len(filter.paths) == 0 { t.Error("should have found ignore files with relative working directory") } else { t.Logf("Successfully processed relative working directory, found %d paths", len(filter.paths)) } }) t.Run("constructor paths are preserved and cause failure if invalid", func(t *testing.T) { t.Parallel() nonExistentPath := "/definitely/does/not/exist/.gitignore" existentPath := filepath.Join(projectRoot, ".gitignore") // Constructor paths are now preserved and processed filter := New(projectRoot, nonExistentPath, existentPath) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) // Should fail because nonExistentPath cannot be opened if err == nil { t.Error("ReadIgnoreFiles should fail when constructor path doesn't exist") } else if !strings.Contains(err.Error(), "does/not/exist") { t.Errorf("expected error about non-existent path, got: %v", err) } }) t.Run("valid constructor paths are processed successfully", func(t *testing.T) { t.Parallel() validPath1 := filepath.Join(projectRoot, ".gitignore") validPath2 := filepath.Join(projectRoot, "testcases", "ignores", "simple", ".gitignore") // Both constructor paths should exist and be valid filter := New(projectRoot, validPath1, validPath2) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("ReadIgnoreFiles should succeed with valid constructor paths: %v", err) } // Should have constructor paths plus any discovered paths if len(filter.paths) < 2 { t.Errorf("should have at least 2 paths (constructor paths), got %d", len(filter.paths)) } // Verify constructor paths are included foundPath1 := false foundPath2 := false for _, path := range filter.paths { if path == validPath1 { foundPath1 = true } if path == validPath2 { foundPath2 = true } } if !foundPath1 { t.Errorf("constructor path %s not found in results: %v", validPath1, filter.paths) } if !foundPath2 { t.Errorf("constructor path %s not found in results: %v", validPath2, filter.paths) } // Verify ignores were compiled for all paths if len(filter.ignores) != len(filter.paths) { t.Errorf("number of compiled ignores (%d) should match number of paths (%d)", len(filter.ignores), len(filter.paths)) } }) t.Run("empty working directory", func(t *testing.T) { t.Parallel() filter := New("") ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) // This might succeed or fail depending on whether current directory is a repo t.Logf("ReadIgnoreFiles with empty wd: error=%v", err) if err == nil { t.Logf("Succeeded with paths: %v", filter.paths) } }) t.Run("very deep nested directory", func(t *testing.T) { t.Parallel() deepPath := filepath.Join(projectRoot, "testcases", "ignores", "nested", "subdir") filter := New(deepPath) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("ReadIgnoreFiles failed with deep path: %v", err) } // Should find ignore files from multiple levels if len(filter.paths) < 2 { t.Errorf( "expected multiple ignore files from different levels, got %d", len(filter.paths), ) } // Verify we get files from different directory levels levels := make(map[string]bool) for _, path := range filter.paths { dir := filepath.Dir(path) levels[dir] = true } if len(levels) < 2 { t.Errorf( "expected ignore files from multiple directory levels, got %d levels", len(levels), ) } }) } func TestFilter_ReadIgnoreFiles_errorRecovery(t *testing.T) { t.Parallel() t.Run("failure with invalid preset paths", func(t *testing.T) { t.Parallel() projectRoot, err := filepath.Abs("../") if err != nil { t.Fatalf("failed to get project root: %v", err) } validPath := filepath.Join(projectRoot, ".gitignore") invalidPath := "/tmp/non-existent-ignore-file" filter := New(projectRoot, validPath, invalidPath) ctx := context.Background() err = filter.ReadIgnoreFiles(ctx) // Should fail because invalidPath cannot be opened if err == nil { t.Error("ReadIgnoreFiles should fail when constructor path doesn't exist") } else if !strings.Contains(err.Error(), "non-existent") { t.Errorf("expected error about non-existent file, got: %v", err) } }) } func TestFilter_ReadIgnoreFiles_stateConsistency(t *testing.T) { t.Parallel() projectRoot, err := filepath.Abs("../") if err != nil { t.Fatalf("failed to get project root: %v", err) } t.Run("filter state after successful operation", func(t *testing.T) { t.Parallel() filter := New(filepath.Join(projectRoot, "testcases", "ignores", "simple")) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("ReadIgnoreFiles failed: %v", err) } // Verify all state is consistent if filter.repo == nil { t.Error("repo should not be nil after successful ReadIgnoreFiles") } if filter.projectRoot == "" { t.Error("projectRoot should not be empty after successful ReadIgnoreFiles") } if filter.wd == "" { t.Error("wd should not be empty (was set in constructor)") } if len(filter.paths) == 0 { t.Error("paths should not be empty after successful ReadIgnoreFiles") } if len(filter.ignores) == 0 { t.Error("ignores should not be empty after successful ReadIgnoreFiles") } // Verify consistency between paths and ignores if len(filter.paths) != len(filter.ignores) { t.Errorf("paths (%d) and ignores (%d) should have same length", len(filter.paths), len(filter.ignores)) } // Verify repo root is absolute path if !filepath.IsAbs(filter.projectRoot) { t.Errorf("projectRoot should be absolute path, got: %s", filter.projectRoot) } // Verify all paths are absolute for i, path := range filter.paths { if !filepath.IsAbs(path) { t.Errorf("path[%d] should be absolute, got: %s", i, path) } } }) t.Run("working directory validation", func(t *testing.T) { t.Parallel() wd := filepath.Join(projectRoot, "testcases", "ignores", "simple") // Verify the working directory actually exists before testing if _, err := os.Stat(wd); err != nil { t.Fatalf("test working directory does not exist: %s", wd) } filter := New(wd) ctx := context.Background() err := filter.ReadIgnoreFiles(ctx) if err != nil { t.Fatalf("ReadIgnoreFiles failed: %v", err) } // Working directory should be preserved if filter.wd != wd { t.Errorf("working directory changed from %s to %s", wd, filter.wd) } // Project root should be parent of or equal to working directory relPath, err := filepath.Rel(filter.projectRoot, wd) if err != nil { t.Errorf("working directory should be within project root: %v", err) } else if strings.HasPrefix(relPath, "..") { t.Errorf("working directory should not be outside project root: %s", relPath) } }) } // Helper function to run shell commands in tests func runCmd(dir string, name string, args ...string) error { cmd := append([]string{name}, args...) return runCmdSlice(dir, cmd) } func runCmdSlice(dir string, cmdSlice []string) error { if len(cmdSlice) == 0 { return errors.New("empty command") } cmd := exec.Command(cmdSlice[0], cmdSlice[1:]...) cmd.Dir = dir return cmd.Run() }