all repos — erl @ d29d0816680a343ee47c40fa9ace8d80862ac412

Execute Reload Loop

feat: apply ignores from .gitignore/.ignore files

Alan Pearce
commit

d29d0816680a343ee47c40fa9ace8d80862ac412

parent

a52be32962b55478c31d6f4e1139e4ab4b140100

M go.modgo.mod
@@ -6,6 +6,8 @@ require github.com/fsnotify/fsnotify v1.9.0
require github.com/jessevdk/go-flags v1.6.1 +require github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 + require ( github.com/fatih/color v1.18.0 github.com/mattn/go-colorable v0.1.13 // indirect
M go.sumgo.sum
@@ -1,3 +1,5 @@
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -9,7 +11,17 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
M gomod2nix.tomlgomod2nix.toml
@@ -16,6 +16,9 @@ hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8="
[mod."github.com/mattn/go-isatty"] version = "v0.0.20" hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" + [mod."github.com/sabhiram/go-gitignore"] + version = "v0.0.0-20210923224102-525f6e181f06" + hash = "sha256-A1aJFlcPRQvDqdJmEwpzhftR8C1Kzv1hXPszq8eO76Q=" [mod."golang.org/x/sys"] version = "v0.25.0" hash = "sha256-PXZ9EQZ7SFpcL7d3E1+KGTxziYlHEIZPfoXEbnaVD3I="
A ignore/ignore.go
@@ -0,0 +1,122 @@
+package ignore + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + ignore "github.com/sabhiram/go-gitignore" + + "alin.ovh/erl/repository" +) + +var DefaultIgnoreFilename = ".ignore" + +type Filter struct { + repo *repository.Repo + projectRoot string + wd string + + givenPaths []string + paths []string + ignores []*Ignore +} + +type Ignore struct { + *ignore.GitIgnore + + filename string +} + +func New(wd string, path ...string) *Filter { + return &Filter{ + wd: wd, + givenPaths: path, + } +} + +func (f *Filter) ReadIgnoreFiles(ctx context.Context) error { + if f.projectRoot == "" { + repo, err := repository.GetRepo(ctx, f.wd) + if err != nil { + return err + } + + f.repo = repo + f.projectRoot = repo.GetRoot() + } + + err := f.getPaths() + if err != nil { + return err + } + + f.ignores = []*Ignore{} + for _, path := range f.paths { + ig, err := ignore.CompileIgnoreFile(path) + if err != nil { + return err + } + + f.ignores = append(f.ignores, &Ignore{ + GitIgnore: ig, + filename: path, + }) + } + + return nil +} + +func (f *Filter) Ignored(path string) bool { + return slices.ContainsFunc(f.ignores, func(ig *Ignore) bool { + return ig.MatchesPath(path) + }) +} + +func (f *Filter) getPaths() error { + f.paths = f.givenPaths + + rel, err := filepath.Rel(f.projectRoot, f.wd) + if err != nil { + return err + } + + ignoreFileNames := []string{DefaultIgnoreFilename} + ignoreFileNames = append(ignoreFileNames, f.repo.GetIgnoreFileNames()...) + dirs := strings.Split(rel, string(filepath.Separator)) + for i := range dirs { + var dir string + if i == 0 { + dir = "." + } else { + dir = filepath.Join(dirs[:i+1]...) + } + + for _, basename := range ignoreFileNames { + filename := filepath.Join(f.projectRoot, dir, basename) + stat, err := os.Stat(filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + + return err + } + if stat.IsDir() { + return fmt.Errorf( + "%s path is a directory: %s", + basename, + filepath.Join(f.projectRoot, dir, filename), + ) + } + + f.paths = append(f.paths, filename) + } + } + + return nil +}
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() +}
M main.gomain.go
@@ -3,6 +3,7 @@
import ( "context" "errors" + "fmt" "io" "log" "os"
@@ -17,6 +18,7 @@ "github.com/fsnotify/fsnotify"
"github.com/jessevdk/go-flags" "alin.ovh/erl/command" + "alin.ovh/erl/ignore" "alin.ovh/erl/state" "alin.ovh/erl/watcher" )
@@ -127,11 +129,10 @@ args = slices.Insert(args, 0, "run")
} } - watcher, err := watcher.New() + wd, err := os.Getwd() if err != nil { - log.Fatal(err) + log.Fatalf("failed to get working directory: %v", err) } - defer watcher.Close() ctx, cancel := signal.NotifyContext( context.Background(),
@@ -140,6 +141,20 @@ syscall.SIGHUP,
syscall.SIGTERM, ) defer cancel() + + filter := ignore.New(wd) + err = filter.ReadIgnoreFiles(ctx) + if err != nil { + panic(fmt.Sprintf("failed to read ignore files: %v", err)) + } + + watcher, err := watcher.New(watcher.Options{ + Filter: *filter, + }) + if err != nil { + panic(fmt.Sprintf("failed to create watcher: %v", err)) + } + defer watcher.Close() copts := command.Options{} if opts.Quiet {
A repository/git/git.go
@@ -0,0 +1,35 @@
+package git + +import ( + "bytes" + "context" + "errors" + "os/exec" +) + +func IsGitRepo(ctx context.Context, wd string) (bool, string, error) { + root, err := showRoot(ctx, wd) + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if exitErr.ExitCode() == 128 { + return false, wd, nil + } + } + + return false, wd, err + } + + return root != "", root, nil +} + +func showRoot(ctx context.Context, wd string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + cmd.Dir = wd + rr, err := cmd.Output() + if err != nil { + return "", err + } + + return string(bytes.TrimSpace(rr)), nil +}
A repository/repo-type.go
@@ -0,0 +1,7 @@
+package repository + +type RepoType int + +const ( + Git RepoType = iota +)
A repository/repo.go
@@ -0,0 +1,51 @@
+package repository + +import ( + "context" + "errors" + + "alin.ovh/erl/repository/git" +) + +type Repo struct { + root string + kind RepoType + ignoreFileNames []string +} + +var ErrNotRepo = errors.New("not a repository") + +func GetRepo(ctx context.Context, wd string) (*Repo, error) { + var ( + matches bool + root string + err error + ) + + matches, root, err = git.IsGitRepo(ctx, wd) + if err != nil { + return nil, err + } + + if matches { + return &Repo{ + root: root, + kind: Git, + ignoreFileNames: []string{".gitignore"}, + }, nil + } + + return nil, ErrNotRepo +} + +func (r Repo) GetRoot() string { + return r.root +} + +func (r Repo) GetKind() RepoType { + return r.kind +} + +func (r Repo) GetIgnoreFileNames() []string { + return r.ignoreFileNames +}
A testcases/ignores/multiple/.gitignore
@@ -0,0 +1,40 @@
+# Compiled source +*.class +*.o +*.so + +# Packages +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases +*.log +*.sql +*.sqlite + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Build directories +build/ +dist/ +target/
A testcases/ignores/multiple/.ignore
@@ -0,0 +1,59 @@
+# Runtime files +*.pid +*.lock +*.sock + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*.swo + +# Cache directories +.cache/ +cache/ +tmp/ + +# Development files +*.dev +*.local +.env.local +.env.development + +# Test files +*.test +test-output/ +coverage/ +.nyc_output/ + +# Documentation build +docs/_build/ +site/ + +# Local configuration +config.local.* +settings.local.* + +# Backup files +*.backup +*.old + +# System files +*.pid +*.seed +*.pid.lock + +# Runtime data +pids/ +logs/ +*.log.* + +# Dependency directories specific to .ignore +vendor/ +.vendor/ + +# Editor backup files +*~ +.#* +#*#
A testcases/ignores/nested/.gitignore
@@ -0,0 +1,13 @@
+# Root level ignores +*.log +*.tmp +build/ +node_modules/ +.env + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store
A testcases/ignores/nested/subdir/.ignore
@@ -0,0 +1,23 @@
+# Subdirectory specific ignores +*.cache +*.bak +test-output/ +coverage/ + +# Local development files +local-config.json +debug.txt + +# Compiled files +*.o +*.so +*.dll + +# Package files +*.jar +*.war +*.nar + +# Test files +test-results/ +*.test
A testcases/ignores/simple/.gitignore
@@ -0,0 +1,18 @@
+# Logs +*.log + +# Build artifacts +build/ +dist/ + +# Temporary files +*.tmp +*.temp + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.idea/
A testcases/runaway/runaway.go
@@ -0,0 +1,34 @@
+package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "syscall" + "time" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + signal.NotifyContext(ctx, os.Interrupt, syscall.SIGHUP) + + cmd := exec.CommandContext(ctx, "sleep", "5") + + go func() { + for { + time.Sleep(5 * time.Second) + fmt.Println("I'm still alive!") + } + }() + + if err := cmd.Start(); err != nil { + panic("Error starting command: " + err.Error()) + } + + if err := cmd.Wait(); err != nil { + fmt.Printf("Error waiting for command: %v\n", err) + } +}
M watcher/watcher.gowatcher/watcher.go
@@ -8,6 +8,8 @@ "path/filepath"
"slices" "github.com/fsnotify/fsnotify" + + "alin.ovh/erl/ignore" ) type Event fsnotify.Event
@@ -23,13 +25,18 @@
// FSWatcher implements the Watcher interface using fsnotify. type FSWatcher struct { watcher fsnotify.Watcher + filter ignore.Filter +} + +type Options struct { + Filter ignore.Filter } var IgnoredDirs = []string{ ".git", } -func New() (*FSWatcher, error) { +func New(options Options) (*FSWatcher, error) { watcher, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create watcher: %v", err)
@@ -37,6 +44,7 @@ }
return &FSWatcher{ watcher: *watcher, + filter: options.Filter, }, nil }
@@ -63,8 +71,10 @@
go func() { for { select { - case event := <-w.watcher.Events: - events <- Event(event) + case event, ok := <-w.watcher.Events: + if ok && event.Name != "" && !w.filter.Ignored(event.Name) { + events <- Event(event) + } case err := <-w.watcher.Errors: errors <- err }
@@ -89,7 +99,7 @@ return err
} if entry.IsDir() { - if slices.Contains(IgnoredDirs, entry.Name()) { + if slices.Contains(IgnoredDirs, entry.Name()) || w.filter.Ignored(path) { return fs.SkipDir }
M watcher/watcher_test.gowatcher/watcher_test.go
@@ -11,7 +11,7 @@ )
func TestNew(t *testing.T) { t.Parallel() - watcher, err := New() + watcher, err := New(Options{}) if err != nil { t.Fatalf("New() failed: %v", err) }
@@ -24,7 +24,7 @@ }
func TestAddRecursive(t *testing.T) { t.Parallel() - watcher, err := New() + watcher, err := New(Options{}) if err != nil { t.Fatalf("New() failed: %v", err) }
@@ -66,7 +66,7 @@ }
func TestAddRecursiveIgnoredDirs(t *testing.T) { t.Parallel() - watcher, err := New() + watcher, err := New(Options{}) if err != nil { t.Fatalf("New() failed: %v", err) }
@@ -107,7 +107,7 @@ }
func TestAddRecursiveNonExistentPath(t *testing.T) { t.Parallel() - watcher, err := New() + watcher, err := New(Options{}) if err != nil { t.Fatalf("New() failed: %v", err) }
@@ -121,7 +121,7 @@ }
func TestRemove(t *testing.T) { t.Parallel() - watcher, err := New() + watcher, err := New(Options{}) if err != nil { t.Fatalf("New() failed: %v", err) }
@@ -158,7 +158,7 @@ }
func TestWatchList(t *testing.T) { t.Parallel() - watcher, err := New() + watcher, err := New(Options{}) if err != nil { t.Fatalf("New() failed: %v", err) }
@@ -189,7 +189,7 @@ }
func TestClose(t *testing.T) { t.Parallel() - watcher, err := New() + watcher, err := New(Options{}) if err != nil { t.Fatalf("New() failed: %v", err) }