diff --git a/CHANGELOG.md b/CHANGELOG.md index 426b3bb4..81fa10ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## unreleased + +### Added + +- Add `--exclude` option to all upload commands to exclude files matching specific patterns. Supports wildcards like `*.map` for file extensions, exact filenames, and substring matching for paths (e.g., `node_modules`, `/dist/`). [#269](https://github.com/bugsnag/bugsnag-cli/pull/269) + +### Changed + +- Configure HTTP client to use HTTP/1.1 instead of HTTP/2 for all upload and build API requests. [#270](https://github.com/bugsnag/bugsnag-cli/pull/270) + ## [3.8.0] - 2026-03-03 ### Changed diff --git a/features/cli/exclude-option.feature b/features/cli/exclude-option.feature new file mode 100644 index 00000000..7eed15f8 --- /dev/null +++ b/features/cli/exclude-option.feature @@ -0,0 +1,35 @@ +Feature: Exclude option tests + + Scenario: Exclude files with wildcard extension pattern + When I run bugsnag-cli with upload js --upload-api-root-url=http://localhost:$MAZE_RUNNER_PORT --api-key=1234567890ABCDEF1234567890ABCDEF --overwrite --base-url=example.com --exclude=*.map --project-root=features/js/fixtures/js-multiple-maps features/js/fixtures/js-multiple-maps/dist/ + Then I should receive no sourcemaps + + Scenario: Exclude files matching specific filename pattern + When I run bugsnag-cli with upload js --upload-api-root-url=http://localhost:$MAZE_RUNNER_PORT --api-key=1234567890ABCDEF1234567890ABCDEF --overwrite --base-url=example.com --exclude=main.js.map --project-root=features/js/fixtures/js-multiple-maps features/js/fixtures/js-multiple-maps/dist/ + And I wait to receive 1 sourcemap + Then the sourcemaps are valid for the API + And the sourcemap payload field "minifiedUrl" equals "example.com/other.js" + + Scenario: Exclude files with multiple patterns + When I run bugsnag-cli with upload js --upload-api-root-url=http://localhost:$MAZE_RUNNER_PORT --api-key=1234567890ABCDEF1234567890ABCDEF --overwrite --base-url=example.com --exclude=main.js.map --exclude=other.js.map --project-root=features/js/fixtures/js-multiple-maps features/js/fixtures/js-multiple-maps/dist/ + Then I should receive no sourcemaps + + Scenario: Exclude with path pattern + When I run bugsnag-cli with upload js --upload-api-root-url=http://localhost:$MAZE_RUNNER_PORT --api-key=1234567890ABCDEF1234567890ABCDEF --overwrite --base-url=example.com --exclude=features/js/fixtures/js-multiple-maps/dist/** --project-root=features/js/fixtures/js-multiple-maps features/js/fixtures/js-multiple-maps/dist/ + Then I should receive no sourcemaps + + Scenario: Exclude with absolute path + Given I get the current working directory + When I run bugsnag-cli with upload js --upload-api-root-url=http://localhost:$MAZE_RUNNER_PORT --api-key=1234567890ABCDEF1234567890ABCDEF --overwrite --base-url=example.com --exclude=$ABS_PATH/features/js/fixtures/js-multiple-maps/dist/other.js.map --project-root=features/js/fixtures/js-multiple-maps features/js/fixtures/js-multiple-maps/dist/ + And I wait to receive 1 sourcemap + Then the sourcemaps are valid for the API + And the sourcemap payload field "minifiedUrl" equals "example.com/main.js" + + Scenario: Exclude with path glob pattern + When I run bugsnag-cli with upload js --upload-api-root-url=http://localhost:$MAZE_RUNNER_PORT --api-key=1234567890ABCDEF1234567890ABCDEF --overwrite --base-url=example.com --exclude=**/dist/** --project-root=features/js/fixtures/js-multiple-maps features/js/fixtures/js-multiple-maps/dist/ + Then I should receive no sourcemaps + + Scenario: Upload succeeds when exclude pattern doesn't match + When I run bugsnag-cli with upload js --upload-api-root-url=http://localhost:$MAZE_RUNNER_PORT --api-key=1234567890ABCDEF1234567890ABCDEF --overwrite --base-url=example.com --exclude=*.log --project-root=features/js/fixtures/js-multiple-maps features/js/fixtures/js-multiple-maps/dist/ + And I wait to receive 2 sourcemaps + Then the sourcemaps are valid for the API diff --git a/features/steps/steps.rb b/features/steps/steps.rb index b54a8676..e2f2c64e 100644 --- a/features/steps/steps.rb +++ b/features/steps/steps.rb @@ -489,3 +489,8 @@ def clean_and_build(scheme, project_path, build_target) Dir.chdir(base_dir) Maze.check.include(`ls #{@fixture_dir}/dist`, 'index.js.map') end + +Given('I get the current working directory') do + @current_dir = Dir.pwd + ENV['ABS_PATH'] = @current_dir +end diff --git a/go.mod b/go.mod index 01d18078..972dae4c 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/bugsnag/bugsnag-cli -go 1.23.1 +go 1.26.1 require ( github.com/alecthomas/kong v0.7.1 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/mattn/go-isatty v0.0.20 github.com/mitchellh/mapstructure v1.5.0 github.com/pkg/errors v0.9.1 @@ -11,6 +12,7 @@ require ( github.com/stretchr/testify v1.8.4 golang.org/x/text v0.14.0 google.golang.org/protobuf v1.34.2 + howett.net/plist v1.0.1 ) require ( @@ -21,5 +23,4 @@ require ( golang.org/x/sys v0.19.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - howett.net/plist v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index fec81fbf..b73af61b 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4 github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -19,8 +21,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= diff --git a/pkg/android/process-uploads.go b/pkg/android/process-uploads.go index 1fd7d99d..961015bb 100644 --- a/pkg/android/process-uploads.go +++ b/pkg/android/process-uploads.go @@ -92,7 +92,7 @@ func UploadAndroidNdk( "/ndk-symbol", params, fileField, - filepath.Base(originalFile), + originalFile, opts, logger, ) diff --git a/pkg/ios/process-dsym.go b/pkg/ios/process-dsym.go index a65a88d6..beb3ef05 100644 --- a/pkg/ios/process-dsym.go +++ b/pkg/ios/process-dsym.go @@ -60,12 +60,28 @@ func ProcessDsymUpload(plistPath string, projectRoot string, options options.CLI } // Attempt to upload the dSYM file. - err = server.ProcessFileRequest(options.ApiKey, "/dsym", uploadOptions, fileFieldData, dsym.UUID, options, logger) + err = server.ProcessFileRequest( + options.ApiKey, + "/dsym", + uploadOptions, + fileFieldData, + dsym.UUID, + options, + logger, + ) if err != nil { // Retry with the base endpoint if a 404 error occurs. if strings.Contains(err.Error(), "404 Not Found") { logger.Debug(fmt.Sprintf("Retrying upload for dSYM %s at base endpoint", dsymInfo)) - err = server.ProcessFileRequest(options.ApiKey, "", uploadOptions, fileFieldData, dsym.UUID, options, logger) + err = server.ProcessFileRequest( + options.ApiKey, + "", + uploadOptions, + fileFieldData, + dsym.UUID, + options, + logger, + ) } if err != nil { return fmt.Errorf("failed to upload dSYM %s: %w", dsymInfo, err) diff --git a/pkg/options/options.go b/pkg/options/options.go index 81b300bb..41424618 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -117,10 +117,10 @@ type Breakpad struct { type Upload struct { // shared options - Retries int `help:"The number of retry attempts before failing an upload request" default:"0"` - Timeout int `help:"The number of seconds to wait before failing an upload request" default:"300"` - UploadAPIRootUrl string `help:"The upload server hostname, optionally containing port number"` - + Retries int `help:"The number of retry attempts before failing an upload request" default:"0"` + Timeout int `help:"The number of seconds to wait before failing an upload request" default:"300"` + UploadAPIRootUrl string `help:"The upload server hostname, optionally containing port number"` + Exclude []string `help:"Exclude files matching these patterns. Supports wildcards (*.map), recursive globs (node_modules/**, **/*.test.js) and exact filenames (file.js.map)."` // required options All DiscoverAndUploadAny `cmd:"" help:"Upload any symbol/mapping files"` AndroidAab AndroidAabMapping `cmd:"" help:"Process and upload application bundle files for Android"` diff --git a/pkg/server/request.go b/pkg/server/request.go index 5276c053..be4fe800 100644 --- a/pkg/server/request.go +++ b/pkg/server/request.go @@ -3,7 +3,6 @@ package server import ( "bytes" "fmt" - "github.com/bugsnag/bugsnag-cli/pkg/endpoints" "io" "mime/multipart" "net/http" @@ -14,6 +13,7 @@ import ( "github.com/pkg/errors" + "github.com/bugsnag/bugsnag-cli/pkg/endpoints" "github.com/bugsnag/bugsnag-cli/pkg/log" "github.com/bugsnag/bugsnag-cli/pkg/options" "github.com/bugsnag/bugsnag-cli/pkg/utils" @@ -121,6 +121,14 @@ func buildFileRequest(url string, fieldData map[string]string, fileFieldData map // - error: An error if any step of the file processing fails. Nil if the process is successful. func ProcessFileRequest(apiKey string, endpointPath string, uploadOptions map[string]string, fileFieldData map[string]FileField, fileName string, options options.CLI, logger log.Logger) error { + // Check if the fileName itself should be excluded based on exclude patterns + if len(options.Upload.Exclude) > 0 { + if utils.IsFileExcluded(fileName, options.Upload.Exclude) { + logger.Info(fmt.Sprintf("Skipping the upload of: %s (matches exclude pattern)", fileName)) + return nil + } + } + if apiKey != "" { uploadOptions["apiKey"] = apiKey } else { @@ -249,8 +257,18 @@ func processRequest(request *http.Request, timeout int, retryCount int, logger l // Returns: // - error: An error if any step of the request processing fails. Nil if the process is successful. func sendRequest(request *http.Request, timeout int, logger log.Logger) error { + // Configure transport to use HTTP/1.1 only + var protocols http.Protocols + protocols.SetHTTP1(true) + protocols.SetHTTP2(false) + + transport := &http.Transport{ + Protocols: &protocols, + } + client := &http.Client{ - Timeout: time.Duration(timeout) * time.Second, + Timeout: time.Duration(timeout) * time.Second, + Transport: transport, } response, err := client.Do(request) diff --git a/pkg/upload/all.go b/pkg/upload/all.go index a155b1db..d7812fcf 100644 --- a/pkg/upload/all.go +++ b/pkg/upload/all.go @@ -48,7 +48,15 @@ func All(options options.CLI, logger log.Logger) error { fileFieldData["file"] = server.LocalFile(file) } - err := server.ProcessFileRequest(options.ApiKey, "", uploadOptions, fileFieldData, file, options, logger) + err := server.ProcessFileRequest( + options.ApiKey, + "", + uploadOptions, + fileFieldData, + file, + options, + logger, + ) if err != nil { return err } diff --git a/pkg/upload/android-proguard.go b/pkg/upload/android-proguard.go index 44610bd3..63565fc0 100644 --- a/pkg/upload/android-proguard.go +++ b/pkg/upload/android-proguard.go @@ -168,12 +168,28 @@ func ProcessAndroidProguard(options options.CLI, logger log.Logger) error { fileFieldData["proguard"] = server.LocalFile(outputFile) // Attempt upload to Bugsnag API - err = server.ProcessFileRequest(options.ApiKey, "/proguard", uploadOptions, fileFieldData, outputFile, options, logger) + err = server.ProcessFileRequest( + options.ApiKey, + "/proguard", + uploadOptions, + fileFieldData, + outputFile, + options, + logger, + ) // Retry at base endpoint if 404 received if err != nil && strings.Contains(err.Error(), "404 Not Found") { logger.Debug("Retrying upload for proguard at base endpoint") - err = server.ProcessFileRequest(options.ApiKey, "", uploadOptions, fileFieldData, outputFile, options, logger) + err = server.ProcessFileRequest( + options.ApiKey, + "", + uploadOptions, + fileFieldData, + outputFile, + options, + logger, + ) } if err != nil { diff --git a/pkg/upload/breakpad.go b/pkg/upload/breakpad.go index 01ca207c..d16db60e 100644 --- a/pkg/upload/breakpad.go +++ b/pkg/upload/breakpad.go @@ -71,7 +71,15 @@ func ProcessBreakpad(globalOptions options.CLI, logger log.Logger) error { ) // Send the file upload request to the Breakpad symbol endpoint - err = server.ProcessFileRequest(apiKey, "/breakpad-symbol"+queryParams, formFields, fileFieldData, file, globalOptions, logger) + err = server.ProcessFileRequest( + apiKey, + "/breakpad-symbol"+queryParams, + formFields, + fileFieldData, + file, + globalOptions, + logger, + ) if err != nil { return err } diff --git a/pkg/upload/dart.go b/pkg/upload/dart.go index e1683074..4b32ad5b 100644 --- a/pkg/upload/dart.go +++ b/pkg/upload/dart.go @@ -48,7 +48,15 @@ func Dart(options options.CLI, logger log.Logger) error { fileFieldData := make(map[string]server.FileField) fileFieldData["symbolFile"] = server.LocalFile(file) - err := server.ProcessFileRequest(options.ApiKey, "/dart-symbol", uploadOptions, fileFieldData, file, options, logger) + err := server.ProcessFileRequest( + options.ApiKey, + "/dart-symbol", + uploadOptions, + fileFieldData, + file, + options, + logger, + ) if err != nil { @@ -91,7 +99,15 @@ func Dart(options options.CLI, logger log.Logger) error { if options.DryRun { err = nil } else { - err = server.ProcessFileRequest(options.ApiKey, "/dart-symbol", uploadOptions, fileFieldData, file, options, logger) + err = server.ProcessFileRequest( + options.ApiKey, + "/dart-symbol", + uploadOptions, + fileFieldData, + file, + options, + logger, + ) } if err != nil { diff --git a/pkg/upload/js.go b/pkg/upload/js.go index a9747d8b..0a36099d 100644 --- a/pkg/upload/js.go +++ b/pkg/upload/js.go @@ -365,7 +365,15 @@ func uploadSingleSourceMap(sourceMapPath string, bundlePath string, bundleUrl st fileFieldData["sourceMap"] = sourceMapFile fileFieldData["minifiedFile"] = server.LocalFile(bundlePath) - err = server.ProcessFileRequest(options.ApiKey, "/sourcemap", uploadOptions, fileFieldData, sourceMapPath, options, logger) + err = server.ProcessFileRequest( + options.ApiKey, + "/sourcemap", + uploadOptions, + fileFieldData, + sourceMapPath, + options, + logger, + ) if err != nil { return fmt.Errorf("encountered error when uploading js sourcemap: %s", err.Error()) diff --git a/pkg/upload/linux.go b/pkg/upload/linux.go index ba2b2475..2d0fce5a 100644 --- a/pkg/upload/linux.go +++ b/pkg/upload/linux.go @@ -49,7 +49,7 @@ func uploadSymbolFile(symbolFile string, linuxOpts options.LinuxOptions, opts op "/linux", uploadOpts, fileField, - filepath.Base(symbolFile), + symbolFile, opts, logger, ); err != nil { diff --git a/pkg/utils/files.go b/pkg/utils/files.go index d47928bf..07ad16ba 100644 --- a/pkg/utils/files.go +++ b/pkg/utils/files.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/bmatcuk/doublestar/v4" ) const ( @@ -51,6 +53,41 @@ func IsDir(path string) bool { return err == nil && pathInfo.IsDir() } +// IsFileExcluded checks if a file path matches any of the exclude patterns. +// It supports wildcards including *.map, path/to/*, and recursive patterns like node_modules/**. +// +// Parameters: +// - filePath: The file path to check. +// - excludePatterns: A list of patterns to match against (supports ** for recursive matching). +// +// Returns: +// - bool: True if the file matches any exclude pattern, false otherwise. +func IsFileExcluded(filePath string, excludePatterns []string) bool { + for _, pattern := range excludePatterns { + // Try matching the pattern against the full path using doublestar (supports **) + matched, err := doublestar.Match(pattern, filePath) + if err == nil && matched { + return true + } + + // If pattern doesn't start with **, also try matching with **/ prepended + // This allows patterns like "node_modules/**" to match anywhere in the path + if !strings.HasPrefix(pattern, "**/") { + matched, err = doublestar.Match("**/"+pattern, filePath) + if err == nil && matched { + return true + } + } + + // Try matching against the base name + matched, err = doublestar.Match(pattern, filepath.Base(filePath)) + if err == nil && matched { + return true + } + } + return false +} + // BuildFileList compiles a list of files from the provided paths. // // Parameters: diff --git a/test/utils/files_test.go b/test/utils/files_test.go index b0d5ed93..32c5e641 100644 --- a/test/utils/files_test.go +++ b/test/utils/files_test.go @@ -50,3 +50,197 @@ func TestFilePathWalkDir(t *testing.T) { } assert.Equal(t, results, []string{"../testdata/android/variants/debug/.gitkeep", "../testdata/android/variants/release/.gitkeep"}, "This should return a file") } + +// TestIsFileExcluded - Tests the IsFileExcluded function +func TestIsFileExcluded(t *testing.T) { + t.Run("Matches wildcard extension pattern", func(t *testing.T) { + excluded := utils.IsFileExcluded("path/to/file.map", []string{"*.map"}) + assert.True(t, excluded, "Should exclude files with .map extension") + + excluded = utils.IsFileExcluded("file.js.map", []string{"*.map"}) + assert.True(t, excluded, "Should exclude files ending with .map") + }) + + t.Run("Matches wildcard path pattern with basename", func(t *testing.T) { + // filepath.Match works on basenames, so node_modules/* will match files in node_modules + // but the actual match happens via substring matching in the implementation + excluded := utils.IsFileExcluded("node_modules/package/file.js", []string{"node_modules"}) + assert.True(t, excluded, "Should exclude files with node_modules in path via substring") + + excluded = utils.IsFileExcluded("src/temp/file.js", []string{"temp"}) + assert.True(t, excluded, "Should exclude files with temp in path via substring") + }) + + t.Run("Matches exact filename", func(t *testing.T) { + excluded := utils.IsFileExcluded("path/to/test.map", []string{"test.map"}) + assert.True(t, excluded, "Should exclude exact filename match") + + excluded = utils.IsFileExcluded("test.map", []string{"test.map"}) + assert.True(t, excluded, "Should exclude exact filename in current dir") + }) + + t.Run("Matches substring path", func(t *testing.T) { + excluded := utils.IsFileExcluded("src/node_modules/lib/file.js", []string{"node_modules"}) + assert.True(t, excluded, "Should exclude files with path containing substring") + + excluded = utils.IsFileExcluded("dist/vendor/bundle.js", []string{"vendor"}) + assert.True(t, excluded, "Should exclude files with vendor in path") + }) + + t.Run("Does not match when pattern doesn't apply", func(t *testing.T) { + excluded := utils.IsFileExcluded("src/main.js", []string{"*.map"}) + assert.False(t, excluded, "Should not exclude .js file with .map pattern") + + excluded = utils.IsFileExcluded("src/components/file.js", []string{"node_modules"}) + assert.False(t, excluded, "Should not exclude files without matching substring") + }) + + t.Run("Handles multiple patterns", func(t *testing.T) { + patterns := []string{"*.map", "*.log", "node_modules"} + + excluded := utils.IsFileExcluded("file.map", patterns) + assert.True(t, excluded, "Should match first pattern") + + excluded = utils.IsFileExcluded("debug.log", patterns) + assert.True(t, excluded, "Should match second pattern") + + excluded = utils.IsFileExcluded("node_modules/lib/file.js", patterns) + assert.True(t, excluded, "Should match third pattern") + + excluded = utils.IsFileExcluded("src/main.js", patterns) + assert.False(t, excluded, "Should not match any pattern") + }) + + t.Run("Handles empty patterns", func(t *testing.T) { + excluded := utils.IsFileExcluded("any/file.js", []string{}) + assert.False(t, excluded, "Should not exclude with no patterns") + + excluded = utils.IsFileExcluded("any/file.js", nil) + assert.False(t, excluded, "Should not exclude with nil patterns") + }) + + t.Run("Handles complex wildcard patterns", func(t *testing.T) { + excluded := utils.IsFileExcluded("test.js.map", []string{"*.js.map"}) + assert.True(t, excluded, "Should match .js.map extension") + + excluded = utils.IsFileExcluded("bundle-v1.2.3.js", []string{"bundle-*.js"}) + assert.True(t, excluded, "Should match bundle with version pattern") + }) + + t.Run("Handles directory path patterns", func(t *testing.T) { + // Substring matching for directory paths + excluded := utils.IsFileExcluded("build/dist/main.js", []string{"build"}) + assert.True(t, excluded, "Should match files with build in path") + + excluded = utils.IsFileExcluded("src/build/main.js", []string{"build"}) + assert.True(t, excluded, "Should match build as substring in path") + + excluded = utils.IsFileExcluded("src/main.js", []string{"build"}) + assert.False(t, excluded, "Should not match when build is not in path") + }) + + t.Run("Supports ** recursive globbing for directories", func(t *testing.T) { + // node_modules/** should match all files under node_modules at the root level + excluded := utils.IsFileExcluded("node_modules/package/file.js", []string{"node_modules/**"}) + assert.True(t, excluded, "Should exclude files in node_modules with ** pattern") + + excluded = utils.IsFileExcluded("node_modules/package/lib/deep/file.js", []string{"node_modules/**"}) + assert.True(t, excluded, "Should exclude deeply nested files in node_modules") + + // node_modules/** only matches if node_modules is at the start of the path + excluded = utils.IsFileExcluded("src/node_modules/package/file.js", []string{"node_modules/**"}) + assert.False(t, excluded, "node_modules/** pattern only matches at path start") + + excluded = utils.IsFileExcluded("src/components/file.js", []string{"node_modules/**"}) + assert.False(t, excluded, "Should not exclude files outside node_modules") + + // To match node_modules at any level, use **/node_modules/** + excluded = utils.IsFileExcluded("src/node_modules/package/file.js", []string{"**/node_modules/**"}) + assert.True(t, excluded, "Should exclude node_modules at any path level with **/node_modules/**") + + excluded = utils.IsFileExcluded("vendor/libs/node_modules/pkg/index.js", []string{"**/node_modules/**"}) + assert.True(t, excluded, "Should exclude node_modules deeply nested with ** pattern") + }) + + t.Run("Supports ** recursive globbing with wildcards", func(t *testing.T) { + // **/*.map should match all .map files anywhere in the tree + excluded := utils.IsFileExcluded("app.js.map", []string{"**/*.map"}) + assert.True(t, excluded, "Should match .map files in root") + + excluded = utils.IsFileExcluded("src/components/app.js.map", []string{"**/*.map"}) + assert.True(t, excluded, "Should match .map files in nested directories") + + excluded = utils.IsFileExcluded("build/dist/vendor/bundle.min.js.map", []string{"**/*.map"}) + assert.True(t, excluded, "Should match .map files deeply nested") + + excluded = utils.IsFileExcluded("src/app.js", []string{"**/*.map"}) + assert.False(t, excluded, "Should not match non-.map files") + }) + + t.Run("Supports ** in middle of path pattern", func(t *testing.T) { + // **/temp/** should match any files in temp directories at any level + excluded := utils.IsFileExcluded("temp/file.js", []string{"**/temp/**"}) + assert.True(t, excluded, "Should match files in root temp directory") + + excluded = utils.IsFileExcluded("src/temp/cache/file.js", []string{"**/temp/**"}) + assert.True(t, excluded, "Should match files in nested temp directory") + + excluded = utils.IsFileExcluded("build/output/temp/intermediate/file.js", []string{"**/temp/**"}) + assert.True(t, excluded, "Should match files in deeply nested temp directories") + + excluded = utils.IsFileExcluded("src/templates/file.js", []string{"**/temp/**"}) + assert.False(t, excluded, "Should not match files outside temp directories") + }) + + t.Run("Supports specific path with ** globbing", func(t *testing.T) { + // src/**/*.test.js should match test files in src and subdirectories + excluded := utils.IsFileExcluded("src/app.test.js", []string{"src/**/*.test.js"}) + assert.True(t, excluded, "Should match test files in src") + + excluded = utils.IsFileExcluded("src/components/button.test.js", []string{"src/**/*.test.js"}) + assert.True(t, excluded, "Should match test files in src subdirectories") + + excluded = utils.IsFileExcluded("src/utils/helpers/format.test.js", []string{"src/**/*.test.js"}) + assert.True(t, excluded, "Should match test files deeply nested in src") + + excluded = utils.IsFileExcluded("test/unit/app.test.js", []string{"src/**/*.test.js"}) + assert.False(t, excluded, "Should not match test files outside src") + + excluded = utils.IsFileExcluded("src/app.js", []string{"src/**/*.test.js"}) + assert.False(t, excluded, "Should not match non-test files in src") + }) + + t.Run("Combines ** patterns with other patterns", func(t *testing.T) { + patterns := []string{"**/*.map", "node_modules/**", "**/dist/**", "*.log"} + + excluded := utils.IsFileExcluded("src/app.js.map", patterns) + assert.True(t, excluded, "Should match .map pattern") + + excluded = utils.IsFileExcluded("node_modules/lib/index.js", patterns) + assert.True(t, excluded, "Should match node_modules pattern") + + excluded = utils.IsFileExcluded("build/dist/bundle.js", patterns) + assert.True(t, excluded, "Should match dist pattern") + + excluded = utils.IsFileExcluded("debug.log", patterns) + assert.True(t, excluded, "Should match .log pattern") + + excluded = utils.IsFileExcluded("src/components/app.js", patterns) + assert.False(t, excluded, "Should not match any pattern") + }) + + t.Run("Handles edge cases with ** patterns", func(t *testing.T) { + // Test various edge cases + excluded := utils.IsFileExcluded("file.js", []string{"**"}) + assert.True(t, excluded, "** should match everything") + + excluded = utils.IsFileExcluded("src/deep/path/file.js", []string{"**"}) + assert.True(t, excluded, "** should match any depth") + + excluded = utils.IsFileExcluded("a/b/c/d/file.js", []string{"**/b/**"}) + assert.True(t, excluded, "Should match with b directory in path") + + excluded = utils.IsFileExcluded("vendor/node_modules/pkg/index.js", []string{"**/node_modules/**"}) + assert.True(t, excluded, "Should match node_modules at any level") + }) +}