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..2ae1bc173df 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/watch.go @@ -7,12 +7,14 @@ import ( "context" "errors" "fmt" + "log" "os" "path/filepath" "time" "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" @@ -28,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)", @@ -71,6 +86,21 @@ 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) + } + + // 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 +122,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 +142,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 +155,25 @@ 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 { + log.Printf("debug: failed to compute relative path for %s: %v", event.Name, relErr) + } else { + 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 +202,31 @@ 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 { + log.Printf("debug: failed to compute relative path for %s: %v", path, relErr) + } else if 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..9fde6cfe612 --- /dev/null +++ b/cli/azd/pkg/ignore/ignore.go @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ignore + +import ( + "bytes" + "errors" + "fmt" + "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 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) { + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolving root path: %w", err) + } + + m := &Matcher{root: absRoot} + + // 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 { + 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..074ef7e0589 --- /dev/null +++ b/cli/azd/pkg/ignore/ignore_test.go @@ -0,0 +1,264 @@ +// 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, "*.log\nvendor/\n") + + m, err := NewMatcher(dir) + require.NoError(t, err) + + // 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) { + // 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) +} + +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)) +} + +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.go b/cli/azd/pkg/watch/watch.go index fe46cc0333f..cdd3339f9f6 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,28 @@ 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 { + log.Printf("debug: failed to compute relative path for %s: %v", name, relErr) + } else { + 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 +169,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 +182,20 @@ 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 { + 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 + } + } + 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_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 7d9c372686e..b41c493b8ac 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" @@ -110,3 +111,296 @@ 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 { + 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 { + foundMain = true + } + } + return foundMain + }, 2*time.Second, 50*time.Millisecond, "expected main.go created without debug.log") +} + +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; vendor/ files should not. + require.Eventually(t, func() bool { + 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 { + foundApp = true + } + } + return foundApp + }, 2*time.Second, 50*time.Millisecond, "expected app.go created without vendor/ files") +} + +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, 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)) + 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") +} + +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") +} + +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") +}