From ad7575e85ebf6332d324ca5997db18ea81065df4 Mon Sep 17 00:00:00 2001 From: Bernhard Guillon Date: Mon, 6 Jul 2026 21:36:29 +0200 Subject: Implement nested .gitignore support with GitignoreStack - Replaced go-gitignore with custom GitignoreStack that tracks multiple .gitignore files hierarchically - Patterns are processed in root-to-leaf order, last match wins - Negation patterns in child dirs override parent ignores - Patterns only apply within their directory scope (no leaking) - Added 6 new tests for nested .gitignore: - Scoped: patterns only affect own directory - Dir-scoped: different dirs can ignore different things - Negation override: child !pattern un-ignores parent's *.pattern - Deep nesting: 3+ levels of .gitignore - Parent scope doesn't leak to siblings - All 35 tests passing --- watcher_test.go | 126 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 81 insertions(+), 45 deletions(-) (limited to 'watcher_test.go') diff --git a/watcher_test.go b/watcher_test.go index 0ea6ca2..db0defb 100644 --- a/watcher_test.go +++ b/watcher_test.go @@ -6,23 +6,21 @@ import ( "path/filepath" "testing" "time" - - ignore "github.com/sabhiram/go-gitignore" ) func TestWatcher_Create(t *testing.T) { host := t.TempDir() data := t.TempDir() - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) @@ -36,15 +34,15 @@ func TestWatcher_CreateInSubdir(t *testing.T) { writeFile(t, host, "src/existing.go", "old") - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) @@ -58,8 +56,8 @@ func TestWatcher_Write(t *testing.T) { writeFile(t, host, "file.txt", "original") - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -68,7 +66,7 @@ func TestWatcher_Write(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) @@ -82,8 +80,8 @@ func TestWatcher_Remove(t *testing.T) { writeFile(t, host, "file.txt", "content") - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -92,7 +90,7 @@ func TestWatcher_Remove(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) @@ -106,8 +104,8 @@ func TestWatcher_Rename(t *testing.T) { writeFile(t, host, "Foo.txt", "content") - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -116,11 +114,10 @@ func TestWatcher_Rename(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) - // Rename: delete old, create new (Linux is case-sensitive) os.Rename(filepath.Join(host, "Foo.txt"), filepath.Join(host, "foo.txt")) writeFile(t, host, "foo.txt", "content") @@ -134,23 +131,18 @@ func TestWatcher_IgnoredFileEvents(t *testing.T) { writeFile(t, host, ".gitignore", "*.log\nbuild/\n") - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } - - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) - // Create ignored files - should NOT sync writeFile(t, host, "debug.log", "log data") writeFile(t, host, "build/output.o", "binary") @@ -159,7 +151,6 @@ func TestWatcher_IgnoredFileEvents(t *testing.T) { assertFileNotExists(t, data, "debug.log") assertFileNotExists(t, data, "build/output.o") - // Create non-ignored file - should sync writeFile(t, host, "app.go", "package main") assertFileEventually(t, data, "app.go", "package main", 2*time.Second) } @@ -168,28 +159,25 @@ func TestWatcher_Debouncing(t *testing.T) { host := t.TempDir() data := t.TempDir() - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) - // Rapid writes to same file writeFile(t, host, "file.txt", "v1") writeFile(t, host, "file.txt", "v2") writeFile(t, host, "file.txt", "v3") writeFile(t, host, "file.txt", "final") - // Wait for debounce + sync assertFileEventually(t, data, "file.txt", "final", 2*time.Second) - // Check that intermediate states don't matter - final content is correct content, err := os.ReadFile(filepath.Join(data, "file.txt")) if err != nil { t.Fatal(err) @@ -203,23 +191,22 @@ func TestWatcher_CreateDirectory(t *testing.T) { host := t.TempDir() data := t.TempDir() - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) - // Create directory and file inside it if err := os.MkdirAll(filepath.Join(host, "newdir"), 0755); err != nil { t.Fatal(err) } - time.Sleep(300 * time.Millisecond) // let watcher register new dir + time.Sleep(300 * time.Millisecond) writeFile(t, host, "newdir/file.txt", "nested") assertDirExistsEventually(t, data, "newdir", 2*time.Second) @@ -232,8 +219,8 @@ func TestWatcher_RemoveDirectory(t *testing.T) { writeFile(t, host, "dir/file.txt", "content") - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -242,7 +229,7 @@ func TestWatcher_RemoveDirectory(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) @@ -256,8 +243,8 @@ func TestWatcher_VerifyManifestUpdated(t *testing.T) { writeFile(t, host, "file.txt", "v1") - rules := ignore.CompileIgnoreLines() - manifest, err := initialSync(host, data, rules, false) + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -268,13 +255,12 @@ func TestWatcher_VerifyManifestUpdated(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go watch(ctx, host, data, manifest, rules, false, false) + go watch(ctx, host, data, manifest, stack, false, false) time.Sleep(100 * time.Millisecond) writeFile(t, host, "file.txt", "version two") - // Wait for sync time.Sleep(500 * time.Millisecond) if manifest.Files["file.txt"].Size != 11 { @@ -282,6 +268,56 @@ func TestWatcher_VerifyManifestUpdated(t *testing.T) { } } +func TestWatcher_NestedGitignore_CreateIgnored(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "sub/.gitignore", "*.tmp\n") + + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, stack, false, false) + + time.Sleep(100 * time.Millisecond) + + writeFile(t, host, "sub/cache.tmp", "tmp") + time.Sleep(500 * time.Millisecond) + + assertFileNotExists(t, data, "sub/cache.tmp") + + writeFile(t, host, "sub/file.go", "code") + assertFileEventually(t, data, "sub/file.go", "code", 2*time.Second) +} + +func TestWatcher_NestedGitignore_CreateUnignore(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, ".gitignore", "*.txt\n") + writeFile(t, host, "sub/.gitignore", "!important.txt\n") + + stack := NewGitignoreStack(host) + manifest, err := initialSync(host, data, stack, false) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, stack, false, false) + + time.Sleep(100 * time.Millisecond) + + writeFile(t, host, "sub/important.txt", "keep me") + assertFileEventually(t, data, "sub/important.txt", "keep me", 2*time.Second) +} + // Helpers func assertFileEventually(t *testing.T, dir, path, expectedContent string, timeout time.Duration) { -- cgit v1.2.3