Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ words:
- toplevel
- azcloud
- azdext
- azdxignore
- azurefd
- azcontainerregistry
- CGNAT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Comment thread
jongio marked this conversation as resolved.

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)",
Expand Down Expand Up @@ -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": {},
Expand All @@ -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)
}

Expand All @@ -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)
Expand All @@ -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{}{}

Expand Down Expand Up @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions cli/azd/pkg/ignore/ignore.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading