feat: apply ignores from .gitignore/.ignore files
1 file changed, 770 insertions(+), 0 deletions(-)
changed files
A ignore/ignore_test.go
@@ -0,0 +1,770 @@ +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() +}