diff --git a/cli/internal/run/run.go b/cli/internal/run/run.go index 8a16fce288229..41a5dea7dcf15 100644 --- a/cli/internal/run/run.go +++ b/cli/internal/run/run.go @@ -34,6 +34,7 @@ import ( "github.com/pyr-sh/dag" + "github.com/bmatcuk/doublestar/v4" "github.com/fatih/color" "github.com/hashicorp/go-hclog" "github.com/mitchellh/cli" @@ -791,24 +792,58 @@ func replayLogs(logger hclog.Logger, output cli.Ui, runOptions *RunOptions, logF logger.Debug("finish replaying logs") } +// hasGlobMeta reports whether string contains any doublestar-supported glob characters. +func hasGlobMeta(s string) bool { + return strings.ContainsAny(s, "*?[{") +} + +// swapColonAndSlash replaces : with / and vice versa, leaving other characters alone. +func swapColonAndSlash(r rune) rune { + switch { + case r == ':': + return '/' + case r == '/': + return ':' + } + return r +} + // GetTargetsFromArguments returns a list of targets from the arguments and Turbo config. // Return targets are always unique sorted alphabetically. func getTargetsFromArguments(arguments []string, configJson *fs.TurboConfigJSON) ([]string, error) { targets := make(util.Set) + tasksAsPaths := make(map[string]string, len(configJson.Pipeline)) + for task := range configJson.Pipeline { + tasksAsPaths[task] = strings.Map(swapColonAndSlash, task) + } for _, arg := range arguments { if arg == "--" { break } if !strings.HasPrefix(arg, "-") { - targets.Add(arg) - found := false - for task := range configJson.Pipeline { - if task == arg { - found = true + if hasGlobMeta(arg) { + matchPattern := strings.Map(swapColonAndSlash, arg) + for task := range tasksAsPaths { + taskAsPath := tasksAsPaths[task] + hasMatch, err := doublestar.Match(matchPattern, taskAsPath) + if err != nil { + return nil, fmt.Errorf("task glob `%v` is invalid: %w", arg, err) + } + if hasMatch { + targets.Add(task) + } + } + } else { + targets.Add(arg) + found := false + for task := range configJson.Pipeline { + if task == arg { + found = true + } + } + if !found { + return nil, fmt.Errorf("task `%v` not found in turbo pipeline in package.json. Are you sure you added it?", arg) } - } - if !found { - return nil, fmt.Errorf("task `%v` not found in turbo pipeline in package.json. Are you sure you added it?", arg) } } } diff --git a/cli/internal/run/run_test.go b/cli/internal/run/run_test.go index d673e1efc964d..132c55e40d1c9 100644 --- a/cli/internal/run/run_test.go +++ b/cli/internal/run/run_test.go @@ -304,6 +304,122 @@ func TestGetTargetsFromArguments(t *testing.T) { } } +func TestGetTargetsFromGlobArguments(t *testing.T) { + type args struct { + arguments []string + configJSON *fs.TurboConfigJSON + } + pipelineConfig := map[string]fs.TaskDefinition{ + "alpha:aaa:check": {}, + "alpha:aaa:update": {}, + "alpha:aaa:build": {}, + "alpha:bbb:check": {}, + "alpha:bbb:build": {}, + "alpha:ccc:iii:update": {}, + "alpha:ccc:iii:build": {}, + "alpha:ccc:jjj:check": {}, + "alpha:ccc:jjj:build": {}, + "beta:a*:update": {}, + "beta:a*:build": {}, + "charlie:ijk:xyz:check": {}, + "charlie:build": {}, + "delta:check": {}, + "echo:check": {}, + "foxtrot:update": {}, + "foxtrot:build": {}, + "foxtrot:aaa:update": {}, + "foxtrot:aaa:clean": {}, + "foxtrot:aaa:456": {}, + "foxtrot:123:clean": {}, + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "handles single glob targets", + args: args{ + arguments: []string{ + "*check", // no results + "alpha:*check", // no results + "alpha:*:build", // alpha:aaa:build alpha:bbb:build + "beta:*", // no results + "beta:a\\*:update", // beta:a*:update + "foxtrot:*", // foxtrot:build foxtrot:update + }, + configJSON: &fs.TurboConfigJSON{ + Pipeline: pipelineConfig, + }, + }, + want: []string{"alpha:aaa:build", "alpha:bbb:build", "beta:a*:update", "foxtrot:build", "foxtrot:update"}, + wantErr: false, + }, + { + name: "handles double glob targets", + args: args{ + arguments: []string{ + "*:**:check", // alpha:aaa:check alpha:bbb:check alpha:ccc:jjj:check charlie:ijk:xyz:check delta:check echo:check + "alpha:**:update", // alpha:aaa:update alpha:ccc:iii:update + "beta:**build", // no results + }, + configJSON: &fs.TurboConfigJSON{ + Pipeline: pipelineConfig, + }, + }, + want: []string{"alpha:aaa:check", "alpha:aaa:update", "alpha:bbb:check", "alpha:ccc:iii:update", "alpha:ccc:jjj:check", "charlie:ijk:xyz:check", "delta:check", "echo:check"}, + wantErr: false, + }, + { + name: "handles single and double glob targets", + args: args{ + arguments: []string{ + "foxtrot:**:*update", // foxtrot:aaa:update foxtrot:update + "*:check", // delta:check echo:check + "alpha:ccc:**:build", // alpha:ccc:iii:build alpha:ccc:jjj:build + "alpha:bbb:*", // alpha:bbb:build alpha:bbb:check + }, + configJSON: &fs.TurboConfigJSON{ + Pipeline: pipelineConfig, + }, + }, + want: []string{"alpha:bbb:build", "alpha:bbb:check", "alpha:ccc:iii:build", "alpha:ccc:jjj:build", "delta:check", "echo:check", "foxtrot:aaa:update", "foxtrot:update"}, + wantErr: false, + }, + { + name: "handles wildcards, braces, and character class glob targets", + args: args{ + arguments: []string{ + "alpha:{a*,b*}:check", // alpha:aaa:check alpha:bbb:check + "alpha:ccc:{iii,jjj}:build", // alpha:ccc:iii:build alpha:ccc:jjj:build + "foxtrot:[a-z][a-z][a-z]:[^0-9]*", // foxtrot:aaa:clean foxtrot:aaa:update + "beta:a?:update", // beta:a*:update + }, + configJSON: &fs.TurboConfigJSON{ + Pipeline: pipelineConfig, + }, + }, + want: []string{"alpha:aaa:check", "alpha:bbb:check", "alpha:ccc:iii:build", "alpha:ccc:jjj:build", "beta:a*:update", "foxtrot:aaa:clean", "foxtrot:aaa:update"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getTargetsFromArguments(tt.args.arguments, tt.args.configJSON) + if (err != nil) != tt.wantErr { + t.Errorf("GetTargetsFromArguments() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetTargetsFromArguments() = %v, want %v", got, tt.want) + } + }) + } +} + func Test_dontSquashTasks(t *testing.T) { topoGraph := &dag.AcyclicGraph{} topoGraph.Add("a") @@ -312,14 +428,14 @@ func Test_dontSquashTasks(t *testing.T) { pipeline := map[string]fs.TaskDefinition{ "build": { - Outputs: []string{}, + Outputs: []string{}, TaskDependencies: []string{"generate"}, }, "generate": { - Outputs: []string{}, + Outputs: []string{}, }, "b#build": { - Outputs: []string{}, + Outputs: []string{}, }, } filteredPkgs := make(util.Set) diff --git a/docs/pages/docs/reference/command-line-reference.mdx b/docs/pages/docs/reference/command-line-reference.mdx index ffe6db74fb372..3aee895bd2712 100644 --- a/docs/pages/docs/reference/command-line-reference.mdx +++ b/docs/pages/docs/reference/command-line-reference.mdx @@ -75,6 +75,18 @@ Run NPM scripts across all packages in specified scope. Tasks must be specified to the tasks to be executed. Note that these additional arguments will _not_ be passed to any additional tasks that are run due to dependencies from the [pipeline](/docs/reference/configuration#pipeline) configuration. +Uses glob patterns via [`doublestar`](https://github.com/bmatcuk/doublestar) under the hood. Specifying multiple glob patterns adds to the result. + +##### doublestar globbing patterns + +Just a quick overview. + +- `*` matches any number of characters, but not `:` +- `?` matches a single character, but not `:` +- `:**:` matches any number of characters, including `:` +- `[class]` matches any single character (but not `:`) in the class of characters (abc or a-z) +- `{alt1,...}` allows for a comma-separated list of "or" expressions + ### Options #### `--cache-dir` @@ -232,7 +244,7 @@ Positive patterns (e.g. `foo` or `*`) add to the results, while negative pattern Therefore a lone negation (e.g. `['!foo']`) will never match anything – use `['*', '!foo']` instead. -##### Globbing patterns +##### multimatch globbing patterns Just a quick overview.