From 3f368e34bec9ecb5d1df1b3952090ce73cc95a6e Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:20:04 -0700 Subject: [PATCH 1/5] Add .azdxignore support for azd x watch (#6152) Add file ignore support so `azd x watch` can skip irrelevant paths (node_modules/, build output, IDE config) that cause unnecessary rebuilds. Changes: - New shared `pkg/ignore` package with Matcher type that loads both `.azdxignore` and `.gitignore` (additive, gitignore syntax) - Integrated into `pkg/watch/watch.go` (conversational agent watcher) - Integrated into `extensions/.../cmd/watch.go` (azd x watch) - Comprehensive tests (37 pass, 1 platform-skip) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 1 + .../internal/cmd/watch.go | 51 +++- cli/azd/pkg/ignore/ignore.go | 101 ++++++++ cli/azd/pkg/ignore/ignore_test.go | 224 ++++++++++++++++++ cli/azd/pkg/watch/watch.go | 59 ++++- cli/azd/pkg/watch/watch_test.go | 164 +++++++++++++ 6 files changed, 585 insertions(+), 15 deletions(-) create mode 100644 cli/azd/pkg/ignore/ignore.go create mode 100644 cli/azd/pkg/ignore/ignore_test.go diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 11ff9c97ec8..1588f9b2a64 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -10,6 +10,7 @@ words: - toplevel - azcloud - azdext + - azdxignore - azurefd - azcontainerregistry - CGNAT diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go index cfdf757c656..2ec8b250658 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go @@ -13,6 +13,7 @@ import ( "github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.extensions/internal" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ignore" "github.com/bmatcuk/doublestar/v4" "github.com/fatih/color" "github.com/fsnotify/fsnotify" @@ -71,6 +72,19 @@ func runWatchAction(ctx context.Context, flags *watchFlags) error { } defer watcher.Close() + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + + // Load ignore patterns from .azdxignore and .gitignore files. + ignoreMatcher, err := ignore.NewMatcher(cwd) + if err != nil { + return fmt.Errorf("failed to load ignore patterns: %w", err) + } + + // Hardcoded folder ignores are kept as a fast-path default — they apply + // even when no .azdxignore or .gitignore file exists. ignoredFolders := map[string]struct{}{ "bin": {}, "obj": {}, @@ -92,7 +106,7 @@ func runWatchAction(ctx context.Context, flags *watchFlags) error { "package-lock.json", // Matches package-lock.json files ) - if err := watchRecursive(".", watcher, ignoredFolders); err != nil { + if err := watchRecursive(cwd, watcher, ignoredFolders, ignoreMatcher); err != nil { return fmt.Errorf("Error watching for changes: %w", err) } @@ -112,7 +126,7 @@ func runWatchAction(ctx context.Context, flags *watchFlags) error { return nil } - // Ignore events matching glob patterns + // Fast path: ignore events matching hardcoded glob patterns. shouldIgnore := false for _, pattern := range globIgnorePaths { matched, _ := doublestar.PathMatch(pattern, event.Name) @@ -125,6 +139,23 @@ func runWatchAction(ctx context.Context, flags *watchFlags) error { continue } + // Check user-defined ignore patterns (.azdxignore / .gitignore). + // Use os.Stat once to determine if the path is a directory. + info, statErr := os.Stat(event.Name) + isDir := statErr == nil && info.IsDir() + + if relPath, relErr := filepath.Rel(cwd, event.Name); relErr == nil { + if ignoreMatcher.IsIgnored(relPath, isDir) { + continue + } + // When the path no longer exists (e.g. Remove event), os.Stat fails + // and isDir defaults to false. Re-check as a directory so that + // directory-only patterns (trailing slash) still filter the event. + if statErr != nil && ignoreMatcher.IsIgnored(relPath, true) { + continue + } + } + // Collect unique changes uniqueChanges[event.Name] = struct{}{} @@ -153,15 +184,29 @@ func runWatchAction(ctx context.Context, flags *watchFlags) error { } } -func watchRecursive(root string, watcher *fsnotify.Watcher, ignoredFolders map[string]struct{}) error { +func watchRecursive( + root string, + watcher *fsnotify.Watcher, + ignoredFolders map[string]struct{}, + ignoreMatcher *ignore.Matcher, +) error { return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { + // Check hardcoded folder ignores. if _, has := ignoredFolders[info.Name()]; has { return filepath.SkipDir } + + // Check user-defined ignore patterns (.azdxignore / .gitignore). + if relPath, relErr := filepath.Rel(root, path); relErr == nil && relPath != "." { + if ignoreMatcher.IsIgnored(relPath, true) { + return filepath.SkipDir + } + } + err = watcher.Add(path) if err != nil { return fmt.Errorf("failed to watch directory %s: %w", path, err) diff --git a/cli/azd/pkg/ignore/ignore.go b/cli/azd/pkg/ignore/ignore.go new file mode 100644 index 00000000000..a947cc94e64 --- /dev/null +++ b/cli/azd/pkg/ignore/ignore.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ignore + +import ( + "bytes" + "errors" + "io/fs" + "os" + "path/filepath" + + gitignore "github.com/denormal/go-gitignore" +) + +const ( + // AzdxIgnoreFile is the name of the azd extension ignore file. + AzdxIgnoreFile = ".azdxignore" + + // GitIgnoreFile is the name of the standard git ignore file. + GitIgnoreFile = ".gitignore" +) + +// Matcher evaluates whether a path should be ignored based on patterns loaded +// from .azdxignore and .gitignore files found in the root directory. +// A nil Matcher is safe to use and never ignores anything. +type Matcher struct { + root string + matchers []gitignore.GitIgnore +} + +// NewMatcher creates a Matcher that loads ignore patterns from the given root directory. +// It attempts to load both .azdxignore and .gitignore — patterns from both files are additive. +// Missing files are silently skipped (no error). A non-nil Matcher is always returned +// even when no ignore files exist (it simply matches nothing). +func NewMatcher(root string) (*Matcher, error) { + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, err + } + + m := &Matcher{root: absRoot} + + // Load .azdxignore first (project-specific rules take precedence in ordering, + // though any match from either file results in ignore). + if ig, loadErr := loadIgnoreFile(absRoot, AzdxIgnoreFile); loadErr != nil { + return nil, loadErr + } else if ig != nil { + m.matchers = append(m.matchers, ig) + } + + // Load .gitignore additively. + if ig, loadErr := loadIgnoreFile(absRoot, GitIgnoreFile); loadErr != nil { + return nil, loadErr + } else if ig != nil { + m.matchers = append(m.matchers, ig) + } + + return m, nil +} + +// IsIgnored reports whether the given path should be ignored. +// path must be relative to the root directory that was passed to NewMatcher. +// isDir indicates whether the path refers to a directory. +// A nil Matcher always returns false. +func (m *Matcher) IsIgnored(path string, isDir bool) bool { + if m == nil || len(m.matchers) == 0 { + return false + } + + // Normalize to forward slashes — gitignore patterns are slash-based. + rel := filepath.ToSlash(path) + + for _, ig := range m.matchers { + if match := ig.Relative(rel, isDir); match != nil && match.Ignore() { + return true + } + } + + return false +} + +// utf8BOM is the byte order mark that some Windows editors prepend to UTF-8 files. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// loadIgnoreFile reads and parses a single ignore file from root/name. +// Returns (nil, nil) if the file does not exist. +func loadIgnoreFile(root, name string) (gitignore.GitIgnore, error) { + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + + // Strip UTF-8 BOM if present — matches pattern used in project_utils.go. + data = bytes.TrimPrefix(data, utf8BOM) + + return gitignore.New(bytes.NewReader(data), root, nil), nil +} diff --git a/cli/azd/pkg/ignore/ignore_test.go b/cli/azd/pkg/ignore/ignore_test.go new file mode 100644 index 00000000000..0af654c629d --- /dev/null +++ b/cli/azd/pkg/ignore/ignore_test.go @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ignore + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewMatcher_NoFiles(t *testing.T) { + dir := t.TempDir() + + m, err := NewMatcher(dir) + require.NoError(t, err) + require.NotNil(t, m) + + // Nothing should be ignored when no ignore files exist. + require.False(t, m.IsIgnored("anything.txt", false)) + require.False(t, m.IsIgnored("node_modules", true)) +} + +func TestNilMatcher_IsIgnored(t *testing.T) { + var m *Matcher + require.False(t, m.IsIgnored("foo.txt", false)) +} + +func TestNewMatcher_AzdxIgnoreOnly(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "node_modules/\n*.log\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + // Directory pattern: matches the directory itself. + require.True(t, m.IsIgnored("node_modules", true)) + // Wildcard pattern: matches files at any depth. + require.True(t, m.IsIgnored("error.log", false)) + require.True(t, m.IsIgnored("sub/dir/debug.log", false)) + + require.False(t, m.IsIgnored("src/main.go", false)) + require.False(t, m.IsIgnored("README.md", false)) +} + +func TestNewMatcher_GitIgnoreOnly(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, GitIgnoreFile, "dist/\n*.o\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + // Directory pattern: matches the directory itself. + require.True(t, m.IsIgnored("dist", true)) + // Wildcard pattern: matches files. + require.True(t, m.IsIgnored("main.o", false)) + + require.False(t, m.IsIgnored("src/main.go", false)) +} + +func TestNewMatcher_BothFilesAdditive(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "build/\n") + writeFile(t, dir, GitIgnoreFile, "node_modules/\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + // Both patterns should apply. + require.True(t, m.IsIgnored("build", true)) + require.True(t, m.IsIgnored("node_modules", true)) + + require.False(t, m.IsIgnored("src/main.go", false)) +} + +func TestNewMatcher_Comments(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "# This is a comment\ntemp/\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + require.True(t, m.IsIgnored("temp", true)) + // The comment line itself should not match anything. + require.False(t, m.IsIgnored("# This is a comment", false)) +} + +func TestNewMatcher_Negation(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "*.log\n!important.log\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + require.True(t, m.IsIgnored("error.log", false)) + // Negation: important.log should NOT be ignored. + require.False(t, m.IsIgnored("important.log", false)) +} + +func TestNewMatcher_WildcardPatterns(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "*.tmp\n**/*.bak\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + require.True(t, m.IsIgnored("file.tmp", false)) + require.True(t, m.IsIgnored("sub/dir/file.bak", false)) + + require.False(t, m.IsIgnored("file.txt", false)) +} + +func TestNewMatcher_DirectoryVsFile(t *testing.T) { + dir := t.TempDir() + + // Trailing slash means "directory only" in gitignore syntax. + writeFile(t, dir, AzdxIgnoreFile, "logs/\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + // "logs" as a directory should be ignored. + require.True(t, m.IsIgnored("logs", true)) + + // "logs" as a file should NOT be ignored (pattern has trailing slash). + require.False(t, m.IsIgnored("logs", false)) +} + +func TestNewMatcher_EmptyFile(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "") + + m, err := NewMatcher(dir) + require.NoError(t, err) + require.False(t, m.IsIgnored("anything", false)) +} + +func TestNewMatcher_OnlyComments(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "# comment 1\n# comment 2\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + require.False(t, m.IsIgnored("anything", false)) +} + +func TestNewMatcher_UTF8BOM(t *testing.T) { + dir := t.TempDir() + + // Write file with BOM prefix — should be stripped transparently. + bom := []byte{0xEF, 0xBB, 0xBF} + content := append(bom, []byte("vendor/\n")...) + err := os.WriteFile(filepath.Join(dir, AzdxIgnoreFile), content, 0600) + require.NoError(t, err) + + m, err := NewMatcher(dir) + require.NoError(t, err) + + require.True(t, m.IsIgnored("vendor", true)) +} + +func TestNewMatcher_RelativePaths(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "sub/dir/\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + require.True(t, m.IsIgnored("sub/dir", true)) + require.True(t, m.IsIgnored(filepath.Join("sub", "dir"), true)) +} + +func TestNewMatcher_BackslashPaths(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, AzdxIgnoreFile, "vendor/\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + // Windows-style backslash paths should still match. + require.True(t, m.IsIgnored("vendor", true)) +} + +func TestNewMatcher_UnreadableFile(t *testing.T) { + // This test verifies that permission errors are propagated. + // On Windows, file permissions work differently, so we skip + // the unreadable-file test there. + if runtime.GOOS == "windows" { + t.Skip("skipping unreadable file test on Windows") + } + + dir := t.TempDir() + p := filepath.Join(dir, AzdxIgnoreFile) + err := os.WriteFile(p, []byte("test\n"), 0600) + require.NoError(t, err) + + // Remove read permission. + err = os.Chmod(p, 0000) + require.NoError(t, err) + t.Cleanup(func() { os.Chmod(p, 0600) }) + + _, err = NewMatcher(dir) + require.Error(t, err) +} + +// writeFile is a test helper that creates a file with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600) + require.NoError(t, err) +} diff --git a/cli/azd/pkg/watch/watch.go b/cli/azd/pkg/watch/watch.go index fe46cc0333f..2dabe2ff648 100644 --- a/cli/azd/pkg/watch/watch.go +++ b/cli/azd/pkg/watch/watch.go @@ -14,6 +14,7 @@ import ( "strings" "sync" + "github.com/azure/azure-dev/cli/azd/pkg/ignore" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/bmatcuk/doublestar/v4" "github.com/fatih/color" @@ -31,6 +32,8 @@ type fileWatcher struct { watcher *fsnotify.Watcher ignoredFolders map[string]struct{} globIgnorePaths []string + ignoreMatcher *ignore.Matcher + root string mu sync.Mutex } @@ -52,7 +55,21 @@ func NewWatcher(ctx context.Context) (Watcher, error) { return nil, fmt.Errorf("failed to create watcher: %w", err) } - // Set up ignore patterns + cwd, err := os.Getwd() + if err != nil { + watcher.Close() + return nil, fmt.Errorf("failed to get current working directory: %w", err) + } + + // Load ignore patterns from .azdxignore and .gitignore files. + ignoreMatcher, err := ignore.NewMatcher(cwd) + if err != nil { + watcher.Close() + return nil, fmt.Errorf("failed to load ignore patterns: %w", err) + } + + // Hardcoded folder ignores are kept as a fast-path default — they apply + // even when no .azdxignore or .gitignore file exists. ignoredFolders := map[string]struct{}{ ".git": {}, } @@ -68,6 +85,8 @@ func NewWatcher(ctx context.Context) (Watcher, error) { watcher: watcher, ignoredFolders: ignoredFolders, globIgnorePaths: globIgnorePaths, + ignoreMatcher: ignoreMatcher, + root: cwd, } go func() { @@ -76,7 +95,7 @@ func NewWatcher(ctx context.Context) (Watcher, error) { for { select { case event := <-watcher.Events: - // Ignore events matching glob patterns + // Fast path: ignore events matching hardcoded glob patterns. shouldIgnore := false for _, pattern := range fw.globIgnorePaths { matched, _ := doublestar.PathMatch(pattern, event.Name) @@ -89,12 +108,26 @@ func NewWatcher(ctx context.Context) (Watcher, error) { continue } - fw.mu.Lock() name := event.Name - // Check if this is a file or directory - info, err := os.Stat(name) - isDir := err == nil && info.IsDir() + // Single os.Stat call — reused for both isDir and ignore matching. + info, statErr := os.Stat(name) + isDir := statErr == nil && info.IsDir() + + // Check user-defined ignore patterns (.azdxignore / .gitignore). + if relPath, relErr := filepath.Rel(fw.root, name); relErr == nil { + if fw.ignoreMatcher.IsIgnored(relPath, isDir) { + continue + } + // When the path no longer exists (e.g. Remove event), os.Stat fails + // and isDir defaults to false. Re-check as a directory so that + // directory-only patterns (trailing slash) still filter the event. + if statErr != nil && fw.ignoreMatcher.IsIgnored(relPath, true) { + continue + } + } + + fw.mu.Lock() switch { case event.Has(fsnotify.Create): @@ -134,11 +167,6 @@ func NewWatcher(ctx context.Context) (Watcher, error) { } }() - cwd, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("failed to get current working directory: %w", err) - } - if err := fw.watchRecursive(cwd, watcher); err != nil { return nil, fmt.Errorf("watcher failed: %w", err) } @@ -152,11 +180,18 @@ func (fw *fileWatcher) watchRecursive(root string, watcher *fsnotify.Watcher) er return err } if info.IsDir() { - // Check if this directory should be ignored + // Check if this directory should be ignored by hardcoded defaults. if _, ignored := fw.ignoredFolders[info.Name()]; ignored { return filepath.SkipDir } + // Check user-defined ignore patterns (.azdxignore / .gitignore). + if relPath, relErr := filepath.Rel(fw.root, path); relErr == nil && relPath != "." { + if fw.ignoreMatcher.IsIgnored(relPath, true) { + return filepath.SkipDir + } + } + err = watcher.Add(path) if err != nil { return fmt.Errorf("failed to watch directory %s: %w", path, err) diff --git a/cli/azd/pkg/watch/watch_test.go b/cli/azd/pkg/watch/watch_test.go index 7d9c372686e..91e7aa2267a 100644 --- a/cli/azd/pkg/watch/watch_test.go +++ b/cli/azd/pkg/watch/watch_test.go @@ -110,3 +110,167 @@ func TestFileChangeType_Values(t *testing.T) { require.Equal(t, FileChangeType(1), FileModified) require.Equal(t, FileChangeType(2), FileDeleted) } + +func TestGetFileChanges_AzdxIgnoreFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Create .azdxignore that excludes *.log files. + err := os.WriteFile(filepath.Join(dir, ".azdxignore"), []byte("*.log\n"), 0600) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + // Write an ignored file and a tracked file. + err = os.WriteFile(filepath.Join(dir, "debug.log"), []byte("log data"), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0600) + require.NoError(t, err) + + // The tracked file should appear; the ignored file should not. + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "main.go" && c.ChangeType == FileCreated { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected created file main.go") + + // Verify the ignored file is not in the changes. + for _, c := range watcher.GetFileChanges() { + require.NotEqual(t, "debug.log", filepath.Base(c.Path), + "debug.log should be ignored by .azdxignore") + } +} + +func TestGetFileChanges_AzdxIgnoreDirectory(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Create .azdxignore that excludes the vendor/ directory. + err := os.WriteFile(filepath.Join(dir, ".azdxignore"), []byte("vendor/\n"), 0600) + require.NoError(t, err) + + // Pre-create the ignored directory and a file inside it. + err = os.MkdirAll(filepath.Join(dir, "vendor", "pkg"), 0700) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + // Write a file inside the ignored directory and a tracked file. + err = os.WriteFile(filepath.Join(dir, "vendor", "pkg", "lib.go"), []byte("package pkg"), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dir, "app.go"), []byte("package main"), 0600) + require.NoError(t, err) + + // The tracked file should appear. + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "app.go" && c.ChangeType == FileCreated { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected created file app.go") + + // Verify no file from vendor/ is in the changes. + for _, c := range watcher.GetFileChanges() { + require.NotContains(t, c.Path, "vendor", + "files inside vendor/ should be ignored by .azdxignore") + } +} + +func TestGetFileChanges_NoAzdxIgnoreFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // No .azdxignore file — watcher should still start without errors. + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + // All files should be tracked when no ignore file exists. + err = os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("hello"), 0600) + require.NoError(t, err) + + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "tracked.txt" && c.ChangeType == FileCreated { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected created file tracked.txt") +} + +func TestGetFileChanges_GitIgnoreRespected(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Create .gitignore that excludes *.tmp files. + err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.tmp\n"), 0600) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + // Write an ignored file and a tracked file. + err = os.WriteFile(filepath.Join(dir, "cache.tmp"), []byte("temp"), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dir, "main.txt"), []byte("content"), 0600) + require.NoError(t, err) + + // The tracked file should appear. + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "main.txt" && c.ChangeType == FileCreated { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected created file main.txt") + + // Verify the ignored file is not in the changes. + for _, c := range watcher.GetFileChanges() { + require.NotEqual(t, "cache.tmp", filepath.Base(c.Path), + "cache.tmp should be ignored by .gitignore") + } +} + +func TestIsIgnored_MatcherIntegration(t *testing.T) { + // Direct test of the ignore matcher as used by the watcher. + // This tests the Relative() code path (not Absolute()) and verifies + // that the matcher is wired correctly into the fileWatcher. + dir := t.TempDir() + t.Chdir(dir) + + err := os.WriteFile(filepath.Join(dir, ".azdxignore"), []byte("dist/\n*.bak\n"), 0600) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + w, err := NewWatcher(ctx) + require.NoError(t, err) + + fw := w.(*fileWatcher) + + // Verify the matcher is loaded and works with relative paths. + require.True(t, fw.ignoreMatcher.IsIgnored("dist", true)) + require.True(t, fw.ignoreMatcher.IsIgnored("file.bak", false)) + require.False(t, fw.ignoreMatcher.IsIgnored("src/main.go", false)) +} From a02ac1692b359d9a6ca5bfd25a498bf8c2510e5e Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:28:49 -0700 Subject: [PATCH 2/5] Improve test quality from test health review - Fix backslash path test to actually exercise path normalization - Add create-then-delete watcher test for delete-from-Created path - Replace hand-rolled indexOf with strings.Index - Add blank/whitespace line edge case test for ignore files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/ignore/ignore_test.go | 21 +++++++++++-- cli/azd/pkg/watch/watch_helpers_test.go | 17 +++-------- cli/azd/pkg/watch/watch_test.go | 39 +++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/cli/azd/pkg/ignore/ignore_test.go b/cli/azd/pkg/ignore/ignore_test.go index 0af654c629d..cadb8b55342 100644 --- a/cli/azd/pkg/ignore/ignore_test.go +++ b/cli/azd/pkg/ignore/ignore_test.go @@ -185,13 +185,16 @@ func TestNewMatcher_RelativePaths(t *testing.T) { func TestNewMatcher_BackslashPaths(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, AzdxIgnoreFile, "vendor/\n") + writeFile(t, dir, AzdxIgnoreFile, "*.log\nvendor/\n") m, err := NewMatcher(dir) require.NoError(t, err) - // Windows-style backslash paths should still match. + // Windows-style backslash paths should still match after ToSlash normalization. require.True(t, m.IsIgnored("vendor", true)) + require.True(t, m.IsIgnored("sub\\dir\\debug.log", false)) + require.True(t, m.IsIgnored("deep\\nested\\path\\error.log", false)) + require.False(t, m.IsIgnored("sub\\dir\\main.go", false)) } func TestNewMatcher_UnreadableFile(t *testing.T) { @@ -222,3 +225,17 @@ func writeFile(t *testing.T, dir, name, content string) { err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600) require.NoError(t, err) } + +func TestNewMatcher_BlankAndWhitespaceLines(t *testing.T) { + dir := t.TempDir() + + // Ignore file with blank lines and whitespace-only lines interspersed. + writeFile(t, dir, AzdxIgnoreFile, "\n \n*.log\n\n\t\ntemp/\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + require.True(t, m.IsIgnored("error.log", false)) + require.True(t, m.IsIgnored("temp", true)) + require.False(t, m.IsIgnored("main.go", false)) +} diff --git a/cli/azd/pkg/watch/watch_helpers_test.go b/cli/azd/pkg/watch/watch_helpers_test.go index 625b4a201da..f0265efb7e2 100644 --- a/cli/azd/pkg/watch/watch_helpers_test.go +++ b/cli/azd/pkg/watch/watch_helpers_test.go @@ -4,6 +4,7 @@ package watch import ( + "strings" "testing" "github.com/stretchr/testify/require" @@ -170,22 +171,12 @@ func TestFileChanges_String_PreservesOrder(t *testing.T) { } s := fc.String() - firstIdx := indexOf(s, "first.txt") - secondIdx := indexOf(s, "second.txt") - thirdIdx := indexOf(s, "third.txt") + firstIdx := strings.Index(s, "first.txt") + secondIdx := strings.Index(s, "second.txt") + thirdIdx := strings.Index(s, "third.txt") require.Greater(t, secondIdx, firstIdx, "second should appear after first") require.Greater(t, thirdIdx, secondIdx, "third should appear after second") } - -// indexOf returns the position of substr in s, or -1. -func indexOf(s, substr string) int { - for i := 0; i+len(substr) <= len(s); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - return -1 -} diff --git a/cli/azd/pkg/watch/watch_test.go b/cli/azd/pkg/watch/watch_test.go index 91e7aa2267a..5a5bb213125 100644 --- a/cli/azd/pkg/watch/watch_test.go +++ b/cli/azd/pkg/watch/watch_test.go @@ -274,3 +274,42 @@ func TestIsIgnored_MatcherIntegration(t *testing.T) { require.True(t, fw.ignoreMatcher.IsIgnored("file.bak", false)) require.False(t, fw.ignoreMatcher.IsIgnored("src/main.go", false)) } + +func TestGetFileChanges_CreateThenDelete(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + // Create a file and wait for it to appear in changes. + testFile := filepath.Join(dir, "ephemeral.txt") + err = os.WriteFile(testFile, []byte("short-lived"), 0600) + require.NoError(t, err) + + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "ephemeral.txt" && c.ChangeType == FileCreated { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected created file ephemeral.txt") + + // Delete the file — it should be removed from Created, not added to Deleted. + err = os.Remove(testFile) + require.NoError(t, err) + + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "ephemeral.txt" { + return false // still present — wait + } + } + return true // gone from all change maps + }, 2*time.Second, 50*time.Millisecond, + "ephemeral.txt should be removed from Created after delete, not moved to Deleted") +} From cbad496fb5aa029cb8cc32c08e579f866e1371f4 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:10:21 -0700 Subject: [PATCH 3/5] Address Copilot review feedback on test assertions - Combine GetFileChanges assertions into single Eventually poll to avoid drain bug - Use filepath.Rel for vendor path check instead of fragile substring match - Use comma-ok type assertion to avoid panics in tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/watch/watch_test.go | 45 +++++++++++++++++---------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/cli/azd/pkg/watch/watch_test.go b/cli/azd/pkg/watch/watch_test.go index 5a5bb213125..a4d03c6fa7a 100644 --- a/cli/azd/pkg/watch/watch_test.go +++ b/cli/azd/pkg/watch/watch_test.go @@ -7,6 +7,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -133,19 +134,18 @@ func TestGetFileChanges_AzdxIgnoreFile(t *testing.T) { // The tracked file should appear; the ignored file should not. require.Eventually(t, func() bool { - for _, c := range watcher.GetFileChanges() { + changes := watcher.GetFileChanges() + foundMain := false + for _, c := range changes { + if filepath.Base(c.Path) == "debug.log" { + return false // ignored file appeared — fail fast + } if filepath.Base(c.Path) == "main.go" && c.ChangeType == FileCreated { - return true + foundMain = true } } - return false - }, 2*time.Second, 50*time.Millisecond, "expected created file main.go") - - // Verify the ignored file is not in the changes. - for _, c := range watcher.GetFileChanges() { - require.NotEqual(t, "debug.log", filepath.Base(c.Path), - "debug.log should be ignored by .azdxignore") - } + return foundMain + }, 2*time.Second, 50*time.Millisecond, "expected main.go created without debug.log") } func TestGetFileChanges_AzdxIgnoreDirectory(t *testing.T) { @@ -172,21 +172,21 @@ func TestGetFileChanges_AzdxIgnoreDirectory(t *testing.T) { err = os.WriteFile(filepath.Join(dir, "app.go"), []byte("package main"), 0600) require.NoError(t, err) - // The tracked file should appear. + // The tracked file should appear; vendor/ files should not. require.Eventually(t, func() bool { - for _, c := range watcher.GetFileChanges() { + changes := watcher.GetFileChanges() + foundApp := false + for _, c := range changes { + rel, err := filepath.Rel(dir, c.Path) + if err == nil && (rel == "vendor" || strings.HasPrefix(filepath.ToSlash(rel), "vendor/")) { + return false // vendor file appeared — fail fast + } if filepath.Base(c.Path) == "app.go" && c.ChangeType == FileCreated { - return true + foundApp = true } } - return false - }, 2*time.Second, 50*time.Millisecond, "expected created file app.go") - - // Verify no file from vendor/ is in the changes. - for _, c := range watcher.GetFileChanges() { - require.NotContains(t, c.Path, "vendor", - "files inside vendor/ should be ignored by .azdxignore") - } + return foundApp + }, 2*time.Second, 50*time.Millisecond, "expected app.go created without vendor/ files") } func TestGetFileChanges_NoAzdxIgnoreFile(t *testing.T) { @@ -267,7 +267,8 @@ func TestIsIgnored_MatcherIntegration(t *testing.T) { w, err := NewWatcher(ctx) require.NoError(t, err) - fw := w.(*fileWatcher) + fw, ok := w.(*fileWatcher) + require.True(t, ok, "expected NewWatcher to return *fileWatcher") // Verify the matcher is loaded and works with relative paths. require.True(t, fw.ignoreMatcher.IsIgnored("dist", true)) From 73e9e2cd0199841ac7a5f9ed077a799950a04052 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:25:00 -0700 Subject: [PATCH 4/5] Address wbreza review: doc clarity, debug logging, rename test - Clarify additive/union semantics in NewMatcher doc comments - Add debug logging for filepath.Rel() errors in both watchers - Document cwd capture as immutable root in extension watcher - Add TestGetFileChanges_RenameFile for rename edge case coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/watch.go | 11 ++++- cli/azd/pkg/ignore/ignore.go | 9 +++-- cli/azd/pkg/watch/watch.go | 8 +++- cli/azd/pkg/watch/watch_test.go | 40 +++++++++++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go index 2ec8b250658..366ee52d42e 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "os" "path/filepath" "time" @@ -72,6 +73,8 @@ func runWatchAction(ctx context.Context, flags *watchFlags) error { } defer watcher.Close() + // cwd is captured once and used as the immutable root for the entire watch session. + // All filepath.Rel calls reference this value so the root cannot drift. cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current working directory: %w", err) @@ -144,7 +147,9 @@ func runWatchAction(ctx context.Context, flags *watchFlags) error { info, statErr := os.Stat(event.Name) isDir := statErr == nil && info.IsDir() - if relPath, relErr := filepath.Rel(cwd, event.Name); relErr == nil { + if relPath, relErr := filepath.Rel(cwd, event.Name); relErr != nil { + log.Printf("debug: failed to compute relative path for %s: %v", event.Name, relErr) + } else { if ignoreMatcher.IsIgnored(relPath, isDir) { continue } @@ -201,7 +206,9 @@ func watchRecursive( } // Check user-defined ignore patterns (.azdxignore / .gitignore). - if relPath, relErr := filepath.Rel(root, path); relErr == nil && relPath != "." { + if relPath, relErr := filepath.Rel(root, path); relErr != nil { + log.Printf("debug: failed to compute relative path for %s: %v", path, relErr) + } else if relPath != "." { if ignoreMatcher.IsIgnored(relPath, true) { return filepath.SkipDir } diff --git a/cli/azd/pkg/ignore/ignore.go b/cli/azd/pkg/ignore/ignore.go index a947cc94e64..4ed3d804f59 100644 --- a/cli/azd/pkg/ignore/ignore.go +++ b/cli/azd/pkg/ignore/ignore.go @@ -30,7 +30,9 @@ type Matcher struct { } // NewMatcher creates a Matcher that loads ignore patterns from the given root directory. -// It attempts to load both .azdxignore and .gitignore — patterns from both files are additive. +// It loads both .azdxignore and .gitignore. Patterns are evaluated additively — a path is +// ignored if it matches ANY pattern in EITHER file. This means .gitignore negation patterns (!) +// cannot un-ignore paths that are matched by .azdxignore, since each file is parsed independently. // Missing files are silently skipped (no error). A non-nil Matcher is always returned // even when no ignore files exist (it simply matches nothing). func NewMatcher(root string) (*Matcher, error) { @@ -41,8 +43,9 @@ func NewMatcher(root string) (*Matcher, error) { m := &Matcher{root: absRoot} - // Load .azdxignore first (project-specific rules take precedence in ordering, - // though any match from either file results in ignore). + // Load .azdxignore first — any match from either file causes the path to be + // ignored (union semantics). Negation patterns in one file do not override + // matches in the other, because each file is parsed independently. if ig, loadErr := loadIgnoreFile(absRoot, AzdxIgnoreFile); loadErr != nil { return nil, loadErr } else if ig != nil { diff --git a/cli/azd/pkg/watch/watch.go b/cli/azd/pkg/watch/watch.go index 2dabe2ff648..cdd3339f9f6 100644 --- a/cli/azd/pkg/watch/watch.go +++ b/cli/azd/pkg/watch/watch.go @@ -115,7 +115,9 @@ func NewWatcher(ctx context.Context) (Watcher, error) { isDir := statErr == nil && info.IsDir() // Check user-defined ignore patterns (.azdxignore / .gitignore). - if relPath, relErr := filepath.Rel(fw.root, name); relErr == nil { + if relPath, relErr := filepath.Rel(fw.root, name); relErr != nil { + log.Printf("debug: failed to compute relative path for %s: %v", name, relErr) + } else { if fw.ignoreMatcher.IsIgnored(relPath, isDir) { continue } @@ -186,7 +188,9 @@ func (fw *fileWatcher) watchRecursive(root string, watcher *fsnotify.Watcher) er } // Check user-defined ignore patterns (.azdxignore / .gitignore). - if relPath, relErr := filepath.Rel(fw.root, path); relErr == nil && relPath != "." { + if relPath, relErr := filepath.Rel(fw.root, path); relErr != nil { + log.Printf("debug: failed to compute relative path for %s: %v", path, relErr) + } else if relPath != "." { if fw.ignoreMatcher.IsIgnored(relPath, true) { return filepath.SkipDir } diff --git a/cli/azd/pkg/watch/watch_test.go b/cli/azd/pkg/watch/watch_test.go index a4d03c6fa7a..6b2f4153504 100644 --- a/cli/azd/pkg/watch/watch_test.go +++ b/cli/azd/pkg/watch/watch_test.go @@ -314,3 +314,43 @@ func TestGetFileChanges_CreateThenDelete(t *testing.T) { }, 2*time.Second, 50*time.Millisecond, "ephemeral.txt should be removed from Created after delete, not moved to Deleted") } + +func TestGetFileChanges_RenameFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + oldFile := filepath.Join(dir, "old.txt") + err = os.WriteFile(oldFile, []byte("content"), 0600) + require.NoError(t, err) + + // Wait for the initial create event. + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "old.txt" { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected old.txt to appear") + + // Rename the file. + newFile := filepath.Join(dir, "new.txt") + err = os.Rename(oldFile, newFile) + require.NoError(t, err) + + // The new name should appear in changes eventually. + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "new.txt" { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected new.txt after rename") +} From d49fa9ef9b3e42efa2fa3acc87859beb768e24c1 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:08:30 -0700 Subject: [PATCH 5/5] address review: cross-file negation test, stat fallback test, error wrap, docs - Add TestNewMatcher_CrossFileNegationCannotOverride to codify that .gitignore negation cannot un-ignore .azdxignore matches (W1/New 2) - Add TestGetFileChanges_DeleteIgnoredDirFallback to test the os.Stat failure re-check with isDir=true for dir-only patterns (W4/New 3) - Wrap filepath.Abs() error with context in NewMatcher (New 4) - Add Long description to watch cobra command documenting .azdxignore usage with example (New 1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/watch.go | 13 +++++ cli/azd/pkg/ignore/ignore.go | 3 +- cli/azd/pkg/ignore/ignore_test.go | 23 +++++++++ cli/azd/pkg/watch/watch_test.go | 50 +++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go index 366ee52d42e..2ae1bc173df 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go @@ -30,6 +30,19 @@ func newWatchCommand() *cobra.Command { watchCmd := &cobra.Command{ Use: "watch", Short: "Watches the azd extension project for file changes and rebuilds it.", + Long: `Watches the azd extension project for file changes and rebuilds it. + +Place a .azdxignore file in your project root to exclude paths from triggering +rebuilds. It uses standard gitignore syntax (https://git-scm.com/docs/gitignore). +Patterns from .gitignore are also respected. Both files are additive — a path is +ignored if it matches either file. + +Example .azdxignore: + dist/ + build/ + *.tmp + coverage/ + !.vscode/launch.json`, RunE: func(cmd *cobra.Command, args []string) error { internal.WriteCommandHeader( "Watch and azd extension (azd x watch)", diff --git a/cli/azd/pkg/ignore/ignore.go b/cli/azd/pkg/ignore/ignore.go index 4ed3d804f59..9fde6cfe612 100644 --- a/cli/azd/pkg/ignore/ignore.go +++ b/cli/azd/pkg/ignore/ignore.go @@ -6,6 +6,7 @@ package ignore import ( "bytes" "errors" + "fmt" "io/fs" "os" "path/filepath" @@ -38,7 +39,7 @@ type Matcher struct { func NewMatcher(root string) (*Matcher, error) { absRoot, err := filepath.Abs(root) if err != nil { - return nil, err + return nil, fmt.Errorf("resolving root path: %w", err) } m := &Matcher{root: absRoot} diff --git a/cli/azd/pkg/ignore/ignore_test.go b/cli/azd/pkg/ignore/ignore_test.go index cadb8b55342..074ef7e0589 100644 --- a/cli/azd/pkg/ignore/ignore_test.go +++ b/cli/azd/pkg/ignore/ignore_test.go @@ -239,3 +239,26 @@ func TestNewMatcher_BlankAndWhitespaceLines(t *testing.T) { require.True(t, m.IsIgnored("temp", true)) require.False(t, m.IsIgnored("main.go", false)) } + +func TestNewMatcher_CrossFileNegationCannotOverride(t *testing.T) { + dir := t.TempDir() + + // .azdxignore ignores all .log files. + writeFile(t, dir, AzdxIgnoreFile, "*.log\n") + // .gitignore tries to un-ignore important.log via negation. + writeFile(t, dir, GitIgnoreFile, "!important.log\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + // important.log is STILL ignored — .gitignore negation cannot override + // .azdxignore matches because each file is parsed independently (union semantics). + require.True(t, m.IsIgnored("important.log", false), + ".gitignore negation must not un-ignore paths matched by .azdxignore") + + // Regular .log files are also ignored. + require.True(t, m.IsIgnored("debug.log", false)) + + // Non-log files are unaffected. + require.False(t, m.IsIgnored("main.go", false)) +} diff --git a/cli/azd/pkg/watch/watch_test.go b/cli/azd/pkg/watch/watch_test.go index 6b2f4153504..b41c493b8ac 100644 --- a/cli/azd/pkg/watch/watch_test.go +++ b/cli/azd/pkg/watch/watch_test.go @@ -354,3 +354,53 @@ func TestGetFileChanges_RenameFile(t *testing.T) { return false }, 2*time.Second, 50*time.Millisecond, "expected new.txt after rename") } + +func TestGetFileChanges_DeleteIgnoredDirFallback(t *testing.T) { + // Tests the os.Stat failure fallback: when a directory matching a dir-only + // ignore pattern (trailing /) is deleted, os.Stat fails and isDir defaults + // to false. The watcher re-checks with isDir=true so that the Remove event + // is still filtered by the directory-only pattern. + dir := t.TempDir() + t.Chdir(dir) + + // .azdxignore uses a dir-only pattern (trailing slash). + err := os.WriteFile(filepath.Join(dir, ".azdxignore"), []byte("tmpout/\n"), 0600) + require.NoError(t, err) + + // Pre-create the directory so watchRecursive skips it (ignored). + err = os.MkdirAll(filepath.Join(dir, "tmpout"), 0700) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + // Delete the ignored directory — the parent watcher fires a Remove event. + // os.Stat will fail (path gone), so isDir defaults to false. + // Without the fallback re-check (isDir=true), this would leak through + // as a file deletion since "tmpout/" only matches directories. + err = os.RemoveAll(filepath.Join(dir, "tmpout")) + require.NoError(t, err) + + // Write a tracked file as a positive signal. + err = os.WriteFile(filepath.Join(dir, "tracked.go"), []byte("package main"), 0600) + require.NoError(t, err) + + // Verify tracked file appears and no tmpout path leaks through. + require.Eventually(t, func() bool { + changes := watcher.GetFileChanges() + foundTracked := false + for _, c := range changes { + if filepath.Base(c.Path) == "tmpout" { + return false // ignored dir leaked through — fail fast + } + if filepath.Base(c.Path) == "tracked.go" && c.ChangeType == FileCreated { + foundTracked = true + } + } + return foundTracked + }, 2*time.Second, 50*time.Millisecond, + "expected tracked.go created without tmpout directory delete leaking through") +}