diff options
| author | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:36:29 +0200 |
|---|---|---|
| committer | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:36:29 +0200 |
| commit | ad7575e85ebf6332d324ca5997db18ea81065df4 (patch) | |
| tree | d2a2e46e2c0432d4b4437c562b4c70de4a6bebf0 | |
| parent | 8b22a36d1c068f9baee68799b0c1f51ecb999e9d (diff) | |
| download | sourcewatch-ad7575e85ebf6332d324ca5997db18ea81065df4.tar.gz sourcewatch-ad7575e85ebf6332d324ca5997db18ea81065df4.zip | |
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
| -rw-r--r-- | gitignore.go | 119 | ||||
| -rw-r--r-- | main.go | 9 | ||||
| -rw-r--r-- | sync.go | 24 | ||||
| -rw-r--r-- | sync_test.go | 197 | ||||
| -rw-r--r-- | watcher.go | 39 | ||||
| -rw-r--r-- | watcher_test.go | 126 |
6 files changed, 374 insertions, 140 deletions
diff --git a/gitignore.go b/gitignore.go index 80d14a6..569e191 100644 --- a/gitignore.go +++ b/gitignore.go @@ -1,18 +1,129 @@ package main import ( + "bufio" "os" "path/filepath" + "strings" ignore "github.com/sabhiram/go-gitignore" ) -func loadGitignore(hostDir string) (*ignore.GitIgnore, error) { - gitignorePath := filepath.Join(hostDir, ".gitignore") +type gitignoreLayer struct { + dir string + posRules *ignore.GitIgnore + negRules *ignore.GitIgnore +} + +type GitignoreStack struct { + rootDir string + layers []gitignoreLayer +} + +func NewGitignoreStack(rootDir string) *GitignoreStack { + stack := &GitignoreStack{rootDir: rootDir} + stack.loadAndPush(rootDir) + return stack +} + +func (s *GitignoreStack) Push(dir string) { + s.loadAndPush(dir) +} + +func (s *GitignoreStack) Pop() { + if len(s.layers) > 0 { + s.layers = s.layers[:len(s.layers)-1] + } +} + +func (s *GitignoreStack) IsIgnored(relPath string) bool { + absPath := filepath.Join(s.rootDir, relPath) + result := false + + for _, layer := range s.layers { + childRel, err := filepath.Rel(layer.dir, absPath) + if err != nil { + continue + } + childRel = filepath.ToSlash(childRel) + if strings.HasPrefix(childRel, "../") { + continue + } + + posMatch := layer.posRules != nil && layer.posRules.MatchesPath(childRel) + negMatch := layer.negRules != nil && layer.negRules.MatchesPath(childRel) + + if negMatch { + result = false + } else if posMatch { + result = true + } + } + return result +} +func (s *GitignoreStack) ParentIgnored(dir string) bool { + parent := filepath.Dir(dir) + if parent == dir { + return false + } + parentRel, err := filepath.Rel(s.rootDir, parent) + if err != nil { + return false + } + if parentRel == "." { + return false + } + return s.IsIgnored(parentRel) +} + +func (s *GitignoreStack) loadAndPush(dir string) { + gitignorePath := filepath.Join(dir, ".gitignore") if _, err := os.Stat(gitignorePath); os.IsNotExist(err) { - return ignore.CompileIgnoreLines(), nil + return + } + + f, err := os.Open(gitignorePath) + if err != nil { + return + } + defer f.Close() + + var posLines, negLines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, "!") { + negLines = append(negLines, trimmed[1:]) + } else { + posLines = append(posLines, trimmed) + } + } + + var posRules, negRules *ignore.GitIgnore + if len(posLines) > 0 { + posRules = ignore.CompileIgnoreLines(posLines...) + } + if len(negLines) > 0 { + negRules = ignore.CompileIgnoreLines(negLines...) } - return ignore.CompileIgnoreFile(gitignorePath) + s.layers = append(s.layers, gitignoreLayer{ + dir: dir, + posRules: posRules, + negRules: negRules, + }) +} + +func hasGitignore(dir string) bool { + _, err := os.Stat(filepath.Join(dir, ".gitignore")) + return err == nil +} + +func isGitDir(path string) bool { + return path == ".git" || strings.HasPrefix(path, ".git"+string(filepath.Separator)) } @@ -25,12 +25,9 @@ func main() { log.Printf("host: %s, data: %s, dry-run: %v", *hostDir, *dataDir, *dryRun) } - rules, err := loadGitignore(*hostDir) - if err != nil { - log.Fatalf("failed to load .gitignore: %v", err) - } + stack := NewGitignoreStack(*hostDir) - manifest, err := initialSync(*hostDir, *dataDir, rules, *dryRun) + manifest, err := initialSync(*hostDir, *dataDir, stack, *dryRun) if err != nil { log.Fatalf("initial sync failed: %v", err) } @@ -38,7 +35,7 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - if err := watch(ctx, *hostDir, *dataDir, manifest, rules, *dryRun, *verbose); err != nil { + if err := watch(ctx, *hostDir, *dataDir, manifest, stack, *dryRun, *verbose); err != nil { log.Fatalf("watcher failed: %v", err) } } @@ -5,22 +5,19 @@ import ( "log" "os" "path/filepath" - "strings" - - ignore "github.com/sabhiram/go-gitignore" ) type FileMeta struct { - Size int64 - Mode os.FileMode - Symlink string + Size int64 + Mode os.FileMode + Symlink string } type Manifest struct { Files map[string]FileMeta } -func initialSync(hostDir, dataDir string, rules *ignore.GitIgnore, dryRun bool) (*Manifest, error) { +func initialSync(hostDir, dataDir string, stack *GitignoreStack, dryRun bool) (*Manifest, error) { manifest := &Manifest{Files: make(map[string]FileMeta)} err := filepath.Walk(hostDir, func(path string, info os.FileInfo, err error) error { @@ -37,11 +34,18 @@ func initialSync(hostDir, dataDir string, rules *ignore.GitIgnore, dryRun bool) return nil } - if relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { - return filepath.SkipDir + if isGitDir(relPath) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + if info.IsDir() && hasGitignore(path) { + stack.Push(path) } - if rules.MatchesPath(relPath) { + if stack.IsIgnored(relPath) { if info.IsDir() { return filepath.SkipDir } diff --git a/sync_test.go b/sync_test.go index 758d4ec..4827a49 100644 --- a/sync_test.go +++ b/sync_test.go @@ -5,8 +5,6 @@ import ( "path/filepath" "sort" "testing" - - ignore "github.com/sabhiram/go-gitignore" ) func TestInitialSync_BasicFiles(t *testing.T) { @@ -17,9 +15,9 @@ func TestInitialSync_BasicFiles(t *testing.T) { writeFile(t, host, "b.txt", "world") writeFile(t, host, "sub/c.txt", "nested") - rules := ignore.CompileIgnoreLines() + stack := NewGitignoreStack(host) - manifest, err := initialSync(host, data, rules, false) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -39,12 +37,9 @@ func TestInitialSync_GitignoreExcludes(t *testing.T) { writeFile(t, host, "debug.log", "logs here") writeFile(t, host, "build/output.o", "binary") - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } + stack := NewGitignoreStack(host) - manifest, err := initialSync(host, data, rules, false) + manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -64,12 +59,9 @@ func TestInitialSync_NegationPattern(t *testing.T) { writeFile(t, host, "debug.log", "debug") writeFile(t, host, "important.log", "important") - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } + stack := NewGitignoreStack(host) - _, err = initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -87,12 +79,9 @@ func TestInitialSync_DoubleStar(t *testing.T) { writeFile(t, host, "temp/cache", "cached") writeFile(t, host, "a/b/temp/data", "temp data") - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } + stack := NewGitignoreStack(host) - _, err = initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -110,12 +99,9 @@ func TestInitialSync_DirectoryPattern(t *testing.T) { writeFile(t, host, "src/app.js", "code") writeFile(t, host, "node_modules/pkg/index.js", "pkg") - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } + stack := NewGitignoreStack(host) - _, err = initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -133,9 +119,9 @@ func TestInitialSync_Symlinks(t *testing.T) { t.Fatal(err) } - rules := ignore.CompileIgnoreLines() + stack := NewGitignoreStack(host) - _, err := initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -167,9 +153,9 @@ func TestInitialSync_PreservesPermissions(t *testing.T) { writeFile(t, host, "script.sh", "#!/bin/sh\necho hi") os.Chmod(filepath.Join(host, "script.sh"), 0755) - rules := ignore.CompileIgnoreLines() + stack := NewGitignoreStack(host) - _, err := initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -192,23 +178,18 @@ func TestInitialSync_DryRun(t *testing.T) { writeFile(t, host, ".gitignore", "*.log\n") writeFile(t, host, "debug.log", "logs") - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } + stack := NewGitignoreStack(host) - manifest, err := initialSync(host, data, rules, true) + manifest, err := initialSync(host, data, stack, true) if err != nil { t.Fatal(err) } - // Dry run should not create any files entries := listDir(t, data) if len(entries) != 0 { t.Errorf("dry run created files: %v", entries) } - // But manifest should be populated assertManifestContains(t, manifest, "a.txt") } @@ -220,9 +201,9 @@ func TestInitialSync_SkipsGit(t *testing.T) { writeFile(t, host, ".git/config", "git config") writeFile(t, host, ".git/objects/abc", "obj") - rules := ignore.CompileIgnoreLines() + stack := NewGitignoreStack(host) - _, err := initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -241,25 +222,9 @@ func TestInitialSync_NestedGitignore(t *testing.T) { writeFile(t, host, "src/debug.log", "log") writeFile(t, host, "src/cache.tmp", "tmp") - // Load root gitignore - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } + stack := NewGitignoreStack(host) - // Load nested gitignore and add its rules - nestedData, err := os.ReadFile(filepath.Join(host, "src", ".gitignore")) - if err == nil { - _ = nestedData // loaded for verification - // Merge: check both root and nested - combined := ignore.CompileIgnoreLines( - "*.log", - "*.tmp", - ) - rules = combined - } - - _, err = initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -301,12 +266,9 @@ build/ writeFile(t, host, "a/b/temp/data", "temp") writeFile(t, host, "file.tmp", "tmp") - rules, err := loadGitignore(host) - if err != nil { - t.Fatal(err) - } + stack := NewGitignoreStack(host) - _, err = initialSync(host, data, rules, false) + _, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } @@ -324,6 +286,121 @@ build/ assertFileNotExists(t, data, "file.tmp") } +func TestInitialSync_NestedGitignore_Scoped(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, ".gitignore", "") + writeFile(t, host, "a/.gitignore", "*.log\n") + writeFile(t, host, "b/.gitignore", "*.tmp\n") + + writeFile(t, host, "a/debug.log", "log") + writeFile(t, host, "a/file.go", "code") + writeFile(t, host, "b/cache.tmp", "tmp") + writeFile(t, host, "b/file.go", "code") + writeFile(t, host, "c/debug.log", "log") + + stack := NewGitignoreStack(host) + + _, err := initialSync(host, data, stack, false) + if err != nil { + t.Fatal(err) + } + + assertFileNotExists(t, data, "a/debug.log") + assertFileExists(t, data, "a/file.go", "code") + assertFileNotExists(t, data, "b/cache.tmp") + assertFileExists(t, data, "b/file.go", "code") + assertFileExists(t, data, "c/debug.log", "log") +} + +func TestInitialSync_NestedGitignore_DirScoped(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, ".gitignore", "build/\n") + writeFile(t, host, "src/.gitignore", "dist/\n") + writeFile(t, host, "src/app.js", "code") + writeFile(t, host, "src/dist/bundle.js", "bundle") + writeFile(t, host, "build/output.o", "built") + + stack := NewGitignoreStack(host) + + _, err := initialSync(host, data, stack, false) + if err != nil { + t.Fatal(err) + } + + assertFileExists(t, data, "src/app.js", "code") + assertFileNotExists(t, data, "src/dist/bundle.js") + assertFileNotExists(t, data, "build/output.o") +} + +func TestInitialSync_NestedGitignore_NegationOverride(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, ".gitignore", "*.txt\n") + writeFile(t, host, "sub/.gitignore", "!important.txt\n") + writeFile(t, host, "sub/readme.txt", "readme") + writeFile(t, host, "sub/important.txt", "important") + + stack := NewGitignoreStack(host) + + _, err := initialSync(host, data, stack, false) + if err != nil { + t.Fatal(err) + } + + assertFileNotExists(t, data, "sub/readme.txt") + assertFileExists(t, data, "sub/important.txt", "important") +} + +func TestInitialSync_NestedGitignore_DeepNesting(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, ".gitignore", "*.log\n") + writeFile(t, host, "a/.gitignore", "*.tmp\n") + writeFile(t, host, "a/b/.gitignore", "*.bak\n") + + writeFile(t, host, "a/b/c/debug.log", "log") + writeFile(t, host, "a/b/c/cache.tmp", "tmp") + writeFile(t, host, "a/b/c/backup.bak", "bak") + writeFile(t, host, "a/b/c/file.go", "code") + + stack := NewGitignoreStack(host) + + _, err := initialSync(host, data, stack, false) + if err != nil { + t.Fatal(err) + } + + assertFileNotExists(t, data, "a/b/c/debug.log") + assertFileNotExists(t, data, "a/b/c/cache.tmp") + assertFileNotExists(t, data, "a/b/c/backup.bak") + assertFileExists(t, data, "a/b/c/file.go", "code") +} + +func TestInitialSync_NestedGitignore_ParentScopeDoesntLeak(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "parent/.gitignore", "*.log\n") + writeFile(t, host, "sibling/file.go", "code") + writeFile(t, host, "sibling/debug.log", "log") + + stack := NewGitignoreStack(host) + + _, err := initialSync(host, data, stack, false) + if err != nil { + t.Fatal(err) + } + + assertFileExists(t, data, "sibling/file.go", "code") + assertFileExists(t, data, "sibling/debug.log", "log") +} + // Helper functions func writeFile(t *testing.T, dir, path, content string) { @@ -5,15 +5,13 @@ import ( "log" "os" "path/filepath" - "strings" "sync" "time" "github.com/fsnotify/fsnotify" - ignore "github.com/sabhiram/go-gitignore" ) -func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool) error { +func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, stack *GitignoreStack, dryRun, verbose bool) error { watcher, err := fsnotify.NewWatcher() if err != nil { return err @@ -23,7 +21,7 @@ func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rul var mu sync.Mutex debounce := make(map[string]*time.Timer) - if err := addWatchersRecursively(watcher, hostDir, rules); err != nil { + if err := addWatchersRecursively(watcher, hostDir, stack); err != nil { return err } @@ -42,11 +40,11 @@ func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rul continue } - if relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { + if isGitDir(relPath) { continue } - if rules.MatchesPath(relPath) { + if stack.IsIgnored(relPath) { continue } @@ -62,7 +60,7 @@ func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rul mu.Lock() delete(debounce, relPath) mu.Unlock() - handleEvent(event.Op, hostDir, dataDir, relPath, manifest, rules, dryRun, verbose, watcher) + handleEvent(event.Op, hostDir, dataDir, relPath, manifest, stack, dryRun, verbose, watcher) }) mu.Unlock() @@ -75,13 +73,13 @@ func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rul } } -func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool, watcher *fsnotify.Watcher) { +func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun, verbose bool, watcher *fsnotify.Watcher) { destPath := filepath.Join(dataDir, relPath) srcPath := filepath.Join(hostDir, relPath) switch { case op&fsnotify.Create != 0: - syncCreate(srcPath, destPath, relPath, manifest, dryRun, verbose, watcher) + syncCreate(srcPath, destPath, relPath, manifest, stack, dryRun, verbose, watcher) case op&fsnotify.Write != 0: syncWrite(srcPath, destPath, relPath, manifest, dryRun, verbose) @@ -94,7 +92,7 @@ func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Man } } -func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, dryRun, verbose bool, watcher *fsnotify.Watcher) { +func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun, verbose bool, watcher *fsnotify.Watcher) { info, err := os.Lstat(srcPath) if err != nil { if os.IsNotExist(err) { @@ -105,6 +103,9 @@ func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, dryRun, v } if info.IsDir() { + if hasGitignore(srcPath) { + stack.Push(srcPath) + } if err := watcher.Add(srcPath); err != nil { log.Printf("error adding watcher for %s: %v", relPath, err) } @@ -190,7 +191,7 @@ func syncRemove(destPath, relPath string, manifest *Manifest, dryRun, verbose bo os.Remove(destPath) } -func addWatchersRecursively(watcher *fsnotify.Watcher, root string, rules *ignore.GitIgnore) error { +func addWatchersRecursively(watcher *fsnotify.Watcher, root string, stack *GitignoreStack) error { return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -201,12 +202,20 @@ func addWatchersRecursively(watcher *fsnotify.Watcher, root string, rules *ignor return err } - if relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { - return filepath.SkipDir + if isGitDir(relPath) { + if info.IsDir() { + return filepath.SkipDir + } + return nil } - if info.IsDir() && !rules.MatchesPath(relPath) { - return watcher.Add(path) + if info.IsDir() { + if hasGitignore(path) { + stack.Push(path) + } + if !stack.IsIgnored(relPath) { + return watcher.Add(path) + } } return nil 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) { |
