package main import ( "context" "os" "path/filepath" "testing" "time" ) func TestWatcher_Create(t *testing.T) { host := t.TempDir() data := t.TempDir() 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) time.Sleep(100 * time.Millisecond) writeFile(t, host, "new.txt", "hello") assertFileEventually(t, data, "new.txt", "hello", 2*time.Second) } func TestWatcher_CreateInSubdir(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "src/existing.go", "old") 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) time.Sleep(100 * time.Millisecond) writeFile(t, host, "src/new.go", "package main") assertFileEventually(t, data, "src/new.go", "package main", 2*time.Second) } func TestWatcher_Write(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "file.txt", "original") stack := NewGitignoreStack(host) manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } assertFileExists(t, data, "file.txt", "original") ctx, cancel := context.WithCancel(context.Background()) defer cancel() go watch(ctx, host, data, manifest, stack, false) time.Sleep(100 * time.Millisecond) writeFile(t, host, "file.txt", "updated") assertFileEventually(t, data, "file.txt", "updated", 2*time.Second) } func TestWatcher_Remove(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "file.txt", "content") stack := NewGitignoreStack(host) manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } assertFileExists(t, data, "file.txt", "content") ctx, cancel := context.WithCancel(context.Background()) defer cancel() go watch(ctx, host, data, manifest, stack, false) time.Sleep(100 * time.Millisecond) os.Remove(filepath.Join(host, "file.txt")) assertFileRemovedEventually(t, data, "file.txt", 2*time.Second) } func TestWatcher_Rename(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "Foo.txt", "content") stack := NewGitignoreStack(host) manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } assertFileExists(t, data, "Foo.txt", "content") ctx, cancel := context.WithCancel(context.Background()) defer cancel() go watch(ctx, host, data, manifest, stack, false) time.Sleep(100 * time.Millisecond) os.Rename(filepath.Join(host, "Foo.txt"), filepath.Join(host, "foo.txt")) writeFile(t, host, "foo.txt", "content") assertFileRemovedEventually(t, data, "Foo.txt", 2*time.Second) assertFileEventually(t, data, "foo.txt", "content", 2*time.Second) } func TestWatcher_IgnoredFileEvents(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, ".gitignore", "*.log\nbuild/\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) time.Sleep(100 * time.Millisecond) writeFile(t, host, "debug.log", "log data") writeFile(t, host, "build/output.o", "binary") time.Sleep(500 * time.Millisecond) assertFileNotExists(t, data, "debug.log") assertFileNotExists(t, data, "build/output.o") writeFile(t, host, "app.go", "package main") assertFileEventually(t, data, "app.go", "package main", 2*time.Second) } func TestWatcher_Debouncing(t *testing.T) { host := t.TempDir() data := t.TempDir() 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) time.Sleep(100 * time.Millisecond) writeFile(t, host, "file.txt", "v1") writeFile(t, host, "file.txt", "v2") writeFile(t, host, "file.txt", "v3") writeFile(t, host, "file.txt", "final") assertFileEventually(t, data, "file.txt", "final", 2*time.Second) content, err := os.ReadFile(filepath.Join(data, "file.txt")) if err != nil { t.Fatal(err) } if string(content) != "final" { t.Errorf("content = %q, want %q", string(content), "final") } } func TestWatcher_CreateDirectory(t *testing.T) { host := t.TempDir() data := t.TempDir() 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) time.Sleep(100 * time.Millisecond) if err := os.MkdirAll(filepath.Join(host, "newdir"), 0755); err != nil { t.Fatal(err) } time.Sleep(300 * time.Millisecond) writeFile(t, host, "newdir/file.txt", "nested") assertDirExistsEventually(t, data, "newdir", 2*time.Second) assertFileEventually(t, data, "newdir/file.txt", "nested", 2*time.Second) } func TestWatcher_RemoveDirectory(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "dir/file.txt", "content") stack := NewGitignoreStack(host) manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } assertFileExists(t, data, "dir/file.txt", "content") ctx, cancel := context.WithCancel(context.Background()) defer cancel() go watch(ctx, host, data, manifest, stack, false) time.Sleep(100 * time.Millisecond) os.RemoveAll(filepath.Join(host, "dir")) assertFileRemovedEventually(t, data, "dir/file.txt", 2*time.Second) } func TestWatcher_VerifyManifestUpdated(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "file.txt", "v1") stack := NewGitignoreStack(host) manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } if manifest.Files["file.txt"].Size != 2 { t.Errorf("initial manifest size = %d, want 2", manifest.Files["file.txt"].Size) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go watch(ctx, host, data, manifest, stack, false) time.Sleep(100 * time.Millisecond) writeFile(t, host, "file.txt", "version two") time.Sleep(500 * time.Millisecond) if manifest.Files["file.txt"].Size != 11 { t.Errorf("manifest size after write = %d, want 11", manifest.Files["file.txt"].Size) } } 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) 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) time.Sleep(100 * time.Millisecond) writeFile(t, host, "sub/important.txt", "keep me") assertFileEventually(t, data, "sub/important.txt", "keep me", 2*time.Second) } func TestWatcher_CreateSymlink(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "target.txt", "target content") 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) time.Sleep(100 * time.Millisecond) if err := os.Symlink("target.txt", filepath.Join(host, "link.txt")); err != nil { t.Fatal(err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { info, err := os.Lstat(filepath.Join(data, "link.txt")) if err == nil && info.Mode()&os.ModeSymlink != 0 { target, _ := os.Readlink(filepath.Join(data, "link.txt")) if target == "target.txt" { return } } time.Sleep(50 * time.Millisecond) } t.Error("symlink not created in /data within timeout") } func TestWatcher_UpdateSymlinkTarget(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "v1.txt", "version 1") writeFile(t, host, "v2.txt", "version 2") if err := os.Symlink("v1.txt", filepath.Join(host, "link.txt")); err != nil { t.Fatal(err) } stack := NewGitignoreStack(host) manifest, err := initialSync(host, data, stack, false) if err != nil { t.Fatal(err) } // Verify initial symlink linkInfo, err := os.Lstat(filepath.Join(data, "link.txt")) if err != nil || linkInfo.Mode()&os.ModeSymlink == 0 { t.Fatal("initial symlink not created") } target, _ := os.Readlink(filepath.Join(data, "link.txt")) if target != "v1.txt" { t.Fatalf("initial symlink target = %q, want %q", target, "v1.txt") } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go watch(ctx, host, data, manifest, stack, false) time.Sleep(100 * time.Millisecond) // Update symlink to point to v2.txt os.Remove(filepath.Join(host, "link.txt")) os.Symlink("v2.txt", filepath.Join(host, "link.txt")) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { info, err := os.Lstat(filepath.Join(data, "link.txt")) if err == nil && info.Mode()&os.ModeSymlink != 0 { tgt, _ := os.Readlink(filepath.Join(data, "link.txt")) if tgt == "v2.txt" { return } } time.Sleep(50 * time.Millisecond) } t.Error("symlink target not updated within timeout") } func TestWatcher_RemoveSymlink(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "target.txt", "content") if err := os.Symlink("target.txt", filepath.Join(host, "link.txt")); err != nil { t.Fatal(err) } 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) time.Sleep(100 * time.Millisecond) os.Remove(filepath.Join(host, "link.txt")) assertFileRemovedEventually(t, data, "link.txt", 2*time.Second) } func TestWatcher_ModifySymlinkTargetFile(t *testing.T) { host := t.TempDir() data := t.TempDir() writeFile(t, host, "target.txt", "original") if err := os.Symlink("target.txt", filepath.Join(host, "link.txt")); err != nil { t.Fatal(err) } 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) time.Sleep(100 * time.Millisecond) // Modify the target file, not the symlink itself writeFile(t, host, "target.txt", "modified") // The symlink should still exist and point to the target deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { info, err := os.Lstat(filepath.Join(data, "link.txt")) if err == nil && info.Mode()&os.ModeSymlink != 0 { // Symlink still exists, which is correct return } time.Sleep(50 * time.Millisecond) } t.Error("symlink lost after target file modification") } // Helpers func assertFileEventually(t *testing.T, dir, path, expectedContent string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { content, err := os.ReadFile(filepath.Join(dir, path)) if err == nil && string(content) == expectedContent { return } time.Sleep(50 * time.Millisecond) } content, _ := os.ReadFile(filepath.Join(dir, path)) t.Errorf("file %s not as expected within %v: got %q, want %q", path, timeout, string(content), expectedContent) } func assertFileRemovedEventually(t *testing.T, dir, path string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { _, err := os.Stat(filepath.Join(dir, path)) if os.IsNotExist(err) { return } time.Sleep(50 * time.Millisecond) } t.Errorf("file %s still exists after %v", path, timeout) } func assertDirExistsEventually(t *testing.T, dir, path string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { info, err := os.Stat(filepath.Join(dir, path)) if err == nil && info.IsDir() { return } time.Sleep(50 * time.Millisecond) } t.Errorf("directory %s not found within %v", path, timeout) }