aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:36:29 +0200
committerBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:36:29 +0200
commitad7575e85ebf6332d324ca5997db18ea81065df4 (patch)
treed2a2e46e2c0432d4b4437c562b4c70de4a6bebf0
parent8b22a36d1c068f9baee68799b0c1f51ecb999e9d (diff)
downloadsourcewatch-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.go119
-rw-r--r--main.go9
-rw-r--r--sync.go24
-rw-r--r--sync_test.go197
-rw-r--r--watcher.go39
-rw-r--r--watcher_test.go126
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))
}
diff --git a/main.go b/main.go
index 436c7b7..24135f5 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
}
diff --git a/sync.go b/sync.go
index 293f20b..526e143 100644
--- a/sync.go
+++ b/sync.go
@@ -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) {
diff --git a/watcher.go b/watcher.go
index 2b56bb4..2067887 100644
--- a/watcher.go
+++ b/watcher.go
@@ -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) {