diff --git a/cli/Makefile b/cli/Makefile index 3e67a1f28b869..c70f88d6544fa 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -8,7 +8,9 @@ GO_FLAGS += "-ldflags=-s -w" # Avoid embedding the build path in the executable for more reproducible builds GO_FLAGS += -trimpath -turbo: cmd/turbo/version.go cmd/turbo/*.go internal/*/*.go go.mod +SRC_FILES = $(shell find . -name "*.go" | grep -v "_test.go") + +turbo: $(SRC_FILES) go.mod CGO_ENABLED=0 go build $(GO_FLAGS) ./cmd/turbo # These tests are for development diff --git a/cli/internal/run/run.go b/cli/internal/run/run.go index 13607f4efe949..a0b4535c080cc 100644 --- a/cli/internal/run/run.go +++ b/cli/internal/run/run.go @@ -117,6 +117,12 @@ Options: --continue Continue execution even if a task exits with an error or non-zero exit code. The default behavior is to bail immediately. (default false) + --filter="" Use the given selector to specify package(s) to act as + entry points. The syntax mirror's pnpm's syntax, and + additional documentation and examples can be found in + turbo's documentation TODO: LINK. + --filter can be specified multiple times. Packages that + match any filter will be included. --force Ignore the existing cache (to force execution). (default false) --graph Generate a Dot graph of the task execution. @@ -190,7 +196,7 @@ func (c *RunCommand) Run(args []string) int { return 1 } } - filteredPkgs, err := scope.ResolvePackages(runOptions.ScopeOpts(), scmInstance, ctx, c.Ui, c.Config.Logger) + filteredPkgs, err := scope.ResolvePackages(runOptions.scopeOpts(), scmInstance, ctx, c.Ui, c.Config.Logger) if err != nil { c.logError(c.Config.Logger, "", fmt.Errorf("failed resolve packages to run %v", err)) } @@ -405,6 +411,8 @@ func buildTaskGraph(topoGraph *dag.AcyclicGraph, pipeline map[string]fs.Pipeline // RunOptions holds the current run operations configuration type RunOptions struct { + // patterns supplied to --filter on the commandline + filterPatterns []string // Whether to include dependent impacted consumers in execution (defaults to true) includeDependents bool // Whether to include includeDependencies (pkg.dependencies) in execution (defaults to false) @@ -450,7 +458,7 @@ type RunOptions struct { dryRunJson bool } -func (ro *RunOptions) ScopeOpts() *scope.Opts { +func (ro *RunOptions) scopeOpts() *scope.Opts { return &scope.Opts{ IncludeDependencies: ro.includeDependencies, IncludeDependents: ro.includeDependents, @@ -459,6 +467,7 @@ func (ro *RunOptions) ScopeOpts() *scope.Opts { Cwd: ro.cwd, IgnorePatterns: ro.ignore, GlobalDepPatterns: ro.globalDeps, + FilterPatterns: ro.filterPatterns, } } @@ -501,6 +510,11 @@ func parseRunArgs(args []string, cwd string, output cli.Ui) (*RunOptions, error) break } else if strings.HasPrefix(arg, "--") { switch { + case strings.HasPrefix(arg, "--filter="): + filterPattern := arg[len("--filter="):] + if filterPattern != "" { + runOptions.filterPatterns = append(runOptions.filterPatterns, filterPattern) + } case strings.HasPrefix(arg, "--since="): if len(arg[len("--since="):]) > 0 { runOptions.since = arg[len("--since="):] diff --git a/cli/internal/run/run_test.go b/cli/internal/run/run_test.go index f8dac5091b94a..3699e87fa9fcb 100644 --- a/cli/internal/run/run_test.go +++ b/cli/internal/run/run_test.go @@ -159,6 +159,22 @@ func TestParseConfig(t *testing.T) { cacheMissLogsMode: FullLogs, }, }, + { + "can specify filter patterns", + []string{"foo", "--filter=bar", "--filter=...[main]"}, + &RunOptions{ + includeDependents: true, + filterPatterns: []string{"bar", "...[main]"}, + stream: true, + bail: true, + concurrency: 10, + cache: true, + cwd: defaultCwd, + cacheFolder: defaultCacheFolder, + cacheHitLogsMode: FullLogs, + cacheMissLogsMode: FullLogs, + }, + }, } ui := &cli.BasicUi{ diff --git a/cli/internal/scope/filter/filter.go b/cli/internal/scope/filter/filter.go new file mode 100644 index 0000000000000..e9aa9561d5e64 --- /dev/null +++ b/cli/internal/scope/filter/filter.go @@ -0,0 +1,348 @@ +package filter + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/pkg/errors" + "github.com/pyr-sh/dag" + "github.com/vercel/turborepo/cli/internal/fs" + "github.com/vercel/turborepo/cli/internal/util" +) + +type SelectedPackages struct { + pkgs util.Set + unusedFilters []*TargetSelector +} + +type PackagesChangedSince = func(since string) (util.Set, error) + +type Resolver struct { + Graph *dag.AcyclicGraph + PackageInfos map[interface{}]*fs.PackageJSON + // SCM scm.SCM + Cwd string + // HasGlobalChange bool + PackagesChangedSince PackagesChangedSince +} + +// GetPackagesFromPatterns compiles filter patterns and applies them, returning +// the selected packages +func (r *Resolver) GetPackagesFromPatterns(patterns []string) (util.Set, error) { + selectors := []*TargetSelector{} + for _, pattern := range patterns { + selector, err := ParseTargetSelector(pattern, r.Cwd) + if err != nil { + return nil, err + } + selectors = append(selectors, &selector) + } + selected, err := r.GetFilteredPackages(selectors) + if err != nil { + return nil, err + } + return selected.pkgs, nil +} + +func (r *Resolver) GetFilteredPackages(selectors []*TargetSelector) (*SelectedPackages, error) { + prodPackageSelectors := []*TargetSelector{} + allPackageSelectors := []*TargetSelector{} + for _, selector := range selectors { + if selector.followProdDepsOnly { + prodPackageSelectors = append(prodPackageSelectors, selector) + } else { + allPackageSelectors = append(allPackageSelectors, selector) + } + } + if len(allPackageSelectors) > 0 || len(prodPackageSelectors) > 0 { + if len(allPackageSelectors) > 0 { + selected, err := r.filterGraph(allPackageSelectors) + if err != nil { + return nil, err + } + return selected, nil + } + } + return &SelectedPackages{ + pkgs: make(util.Set), + }, nil +} + +func (r *Resolver) filterGraph(selectors []*TargetSelector) (*SelectedPackages, error) { + includeSelectors := []*TargetSelector{} + excludeSelectors := []*TargetSelector{} + for _, selector := range selectors { + if selector.exclude { + excludeSelectors = append(excludeSelectors, selector) + } else { + includeSelectors = append(includeSelectors, selector) + } + } + var include *SelectedPackages + if len(includeSelectors) > 0 { + found, err := r.filterGraphWithSelectors(includeSelectors) + if err != nil { + return nil, err + } + include = found + } else { + vertexSet := make(util.Set) + for _, v := range r.Graph.Vertices() { + vertexSet.Add(v) + } + include = &SelectedPackages{ + pkgs: vertexSet, + } + } + exclude, err := r.filterGraphWithSelectors(excludeSelectors) + if err != nil { + return nil, err + } + return &SelectedPackages{ + pkgs: include.pkgs.Difference(exclude.pkgs), + unusedFilters: append(include.unusedFilters, exclude.unusedFilters...), + }, nil +} + +func (r *Resolver) filterGraphWithSelectors(selectors []*TargetSelector) (*SelectedPackages, error) { + unmatchedSelectors := []*TargetSelector{} + + cherryPickedPackages := make(dag.Set) + walkedDependencies := make(dag.Set) + walkedDependents := make(dag.Set) + walkedDependentsDependencies := make(dag.Set) + + for _, selector := range selectors { + // TODO(gsoltis): this should be a list? + entryPackages, err := r.filterGraphWithSelector(selector) + if err != nil { + return nil, err + } + if entryPackages.Len() == 0 { + unmatchedSelectors = append(unmatchedSelectors, selector) + } + for _, pkg := range entryPackages { + if selector.includeDependencies { + dependencies, err := r.Graph.Ancestors(pkg) + if err != nil { + return nil, errors.Wrapf(err, "failed to get dependencies of package %v", pkg) + } + for dep := range dependencies { + walkedDependencies.Add(dep) + } + if !selector.excludeSelf { + walkedDependencies.Add(pkg) + } + } + if selector.includeDependents { + dependents, err := r.Graph.Descendents(pkg) + if err != nil { + return nil, errors.Wrapf(err, "failed to get dependents of package %v", pkg) + } + for dep := range dependents { + walkedDependents.Add(dep) + if selector.includeDependencies { + dependentDeps, err := r.Graph.Ancestors(dep) + if err != nil { + return nil, errors.Wrapf(err, "failed to get dependencies of dependent %v", dep) + } + for dependentDep := range dependentDeps { + walkedDependentsDependencies.Add(dependentDep) + } + } + } + if !selector.excludeSelf { + walkedDependents.Add(pkg) + } + } + if !selector.includeDependencies && !selector.includeDependents { + cherryPickedPackages.Add(pkg) + } + } + } + allPkgs := make(util.Set) + for pkg := range cherryPickedPackages { + allPkgs.Add(pkg) + } + for pkg := range walkedDependencies { + allPkgs.Add(pkg) + } + for pkg := range walkedDependents { + allPkgs.Add(pkg) + } + for pkg := range walkedDependentsDependencies { + allPkgs.Add(pkg) + } + return &SelectedPackages{ + pkgs: allPkgs, + unusedFilters: unmatchedSelectors, + }, nil +} + +func (r *Resolver) filterGraphWithSelector(selector *TargetSelector) (util.Set, error) { + if selector.matchDependencies { + return r.filterSubtreesWithSelector(selector) + } + return r.filterNodesWithSelector(selector) +} + +// filterNodesWithSelector returns the set of nodes that match a given selector +func (r *Resolver) filterNodesWithSelector(selector *TargetSelector) (util.Set, error) { + entryPackages := make(util.Set) + selectorWasUsed := false + if selector.diff != "" { + // get changed packaged + selectorWasUsed = true + changedPkgs, err := r.PackagesChangedSince(selector.diff) + if err != nil { + return nil, err + } + parentDir := selector.parentDir + for pkgName := range changedPkgs { + if parentDir != "" { + if pkg, ok := r.PackageInfos[pkgName]; !ok { + return nil, fmt.Errorf("missing info for package %v", pkgName) + } else if matches, err := doublestar.PathMatch(parentDir, filepath.Join(r.Cwd, pkg.Dir)); err != nil { + return nil, fmt.Errorf("failed to resolve directory relationship %v contains %v: %v", selector.parentDir, pkg.Dir, err) + } else if matches { + entryPackages.Add(pkgName) + } + } else { + entryPackages.Add(pkgName) + } + } + } else if selector.parentDir != "" { + // get packages by path + selectorWasUsed = true + parentDir := selector.parentDir + for name, pkg := range r.PackageInfos { + if matches, err := doublestar.PathMatch(parentDir, filepath.Join(r.Cwd, pkg.Dir)); err != nil { + return nil, fmt.Errorf("failed to resolve directory relationship %v contains %v: %v", selector.parentDir, pkg.Dir, err) + } else if matches { + entryPackages.Add(name) + } + } + } + if selector.namePattern != "" { + // find packages that match name + if !selectorWasUsed { + matched, err := matchPackageNamesToVertices(selector.namePattern, r.Graph.Vertices()) + if err != nil { + return nil, err + } + entryPackages = matched + selectorWasUsed = true + } else { + matched, err := matchPackageNames(selector.namePattern, entryPackages) + if err != nil { + return nil, err + } + entryPackages = matched + } + } + // TODO(gsoltis): we can do this earlier + // Check if the selector specified anything + if !selectorWasUsed { + return nil, fmt.Errorf("invalid selector: %v", selector.raw) + } + return entryPackages, nil +} + +// filterSubtreesWithSelector returns the set of nodes where the node or any of its dependencies +// match a selector +func (r *Resolver) filterSubtreesWithSelector(selector *TargetSelector) (util.Set, error) { + // foreach package that matches parentDir && namePattern, check if any dependency is in changed packages + changedPkgs, err := r.PackagesChangedSince(selector.diff) + if err != nil { + return nil, err + } + + parentDir := "" + if selector.parentDir != "" { + parentDir = filepath.Join(r.Cwd, selector.parentDir) + } + entryPackages := make(util.Set) + for name, pkg := range r.PackageInfos { + if parentDir == "" { + entryPackages.Add(name) + } else if matches, err := doublestar.PathMatch(parentDir, pkg.Dir); err != nil { + return nil, fmt.Errorf("failed to resolve directory relationship %v contains %v: %v", selector.parentDir, pkg.Dir, err) + } else if matches { + entryPackages.Add(name) + } + } + if selector.namePattern != "" { + matched, err := matchPackageNames(selector.namePattern, entryPackages) + if err != nil { + return nil, err + } + entryPackages = matched + } + roots := make(util.Set) + matched := make(util.Set) + for pkg := range entryPackages { + if matched.Includes(pkg) { + roots.Add(pkg) + continue + } + deps, err := r.Graph.Ancestors(pkg) + if err != nil { + return nil, err + } + for changedPkg := range changedPkgs { + if deps.Include(changedPkg) { + roots.Add(pkg) + matched.Add(changedPkg) + break + } + } + } + return roots, nil +} + +func matchPackageNamesToVertices(pattern string, vertices []dag.Vertex) (util.Set, error) { + packages := make(util.Set) + for _, v := range vertices { + packages.Add(v) + } + return matchPackageNames(pattern, packages) +} + +func matchPackageNames(pattern string, packages util.Set) (util.Set, error) { + matcher, err := matcherFromPattern(pattern) + if err != nil { + return nil, err + } + matched := make(util.Set) + for _, pkg := range packages { + pkg := pkg.(string) + if matcher(pkg) { + matched.Add(pkg) + } + } + if matched.Len() == 0 && !strings.HasPrefix(pattern, "@") && !strings.Contains(pattern, "/") { + // we got no matches and the pattern isn't a scoped package. + // Check if we have exactly one scoped package that does match + scopedPattern := fmt.Sprintf("@*/%v", pattern) + matcher, err = matcherFromPattern(scopedPattern) + if err != nil { + return nil, err + } + foundScopedPkg := false + for _, pkg := range packages { + pkg := pkg.(string) + if matcher(pkg) { + if foundScopedPkg { + // we found a second scoped package. Return the empty set, we can't + // disambiguate + return make(util.Set), nil + } + foundScopedPkg = true + matched.Add(pkg) + } + } + } + return matched, nil +} diff --git a/cli/internal/scope/filter/filter_test.go b/cli/internal/scope/filter/filter_test.go new file mode 100644 index 0000000000000..96f955ad04a22 --- /dev/null +++ b/cli/internal/scope/filter/filter_test.go @@ -0,0 +1,454 @@ +package filter + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pyr-sh/dag" + "github.com/vercel/turborepo/cli/internal/fs" + "github.com/vercel/turborepo/cli/internal/util" +) + +func setMatches(t *testing.T, name string, s util.Set, expected []string) { + expectedSet := make(util.Set) + for _, item := range expected { + expectedSet.Add(item) + } + missing := s.Difference(expectedSet) + if missing.Len() > 0 { + t.Errorf("%v set has extra elements: %v", name, strings.Join(missing.UnsafeListOfStrings(), ", ")) + } + extra := expectedSet.Difference(s) + if extra.Len() > 0 { + t.Errorf("%v set missing elements: %v", name, strings.Join(extra.UnsafeListOfStrings(), ", ")) + } +} + +func Test_filter(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + packageJSONs := make(map[interface{}]*fs.PackageJSON) + graph := &dag.AcyclicGraph{} + graph.Add("project-0") + packageJSONs["project-0"] = &fs.PackageJSON{ + Name: "project-0", + Dir: filepath.Join("packages", "project-0"), + } + graph.Add("project-1") + packageJSONs["project-1"] = &fs.PackageJSON{ + Name: "project-1", + Dir: filepath.Join("packages", "project-1"), + } + graph.Add("project-2") + packageJSONs["project-2"] = &fs.PackageJSON{ + Name: "project-2", + Dir: "project-2", + } + graph.Add("project-3") + packageJSONs["project-3"] = &fs.PackageJSON{ + Name: "project-3", + Dir: "project-3", + } + graph.Add("project-4") + packageJSONs["project-4"] = &fs.PackageJSON{ + Name: "project-4", + Dir: "project-4", + } + graph.Add("project-5") + packageJSONs["project-5"] = &fs.PackageJSON{ + Name: "project-5", + Dir: "project-5", + } + // Note: inside project-5 + graph.Add("project-6") + packageJSONs["project-6"] = &fs.PackageJSON{ + Name: "project-6", + Dir: filepath.Join("project-5", "packages", "project-6"), + } + // Add dependencies + graph.Connect(dag.BasicEdge("project-0", "project-1")) + graph.Connect(dag.BasicEdge("project-0", "project-5")) + graph.Connect(dag.BasicEdge("project-1", "project-2")) + graph.Connect(dag.BasicEdge("project-1", "project-4")) + + r := &Resolver{ + Graph: graph, + PackageInfos: packageJSONs, + Cwd: root, + } + + testCases := []struct { + Name string + Selectors []*TargetSelector + Expected []string + }{ + { + "select only package dependencies (excluding the package itself)", + []*TargetSelector{ + { + excludeSelf: true, + includeDependencies: true, + namePattern: "project-1", + }, + }, + []string{"project-2", "project-4"}, + }, + { + "select package with dependencies", + []*TargetSelector{ + { + excludeSelf: false, + includeDependencies: true, + namePattern: "project-1", + }, + }, + []string{"project-1", "project-2", "project-4"}, + }, + { + "select package with dependencies and dependents, including dependent dependencies", + []*TargetSelector{ + { + excludeSelf: true, + includeDependencies: true, + includeDependents: true, + namePattern: "project-1", + }, + }, + []string{"project-0", "project-1", "project-2", "project-4", "project-5"}, + }, + { + "select package with dependents", + []*TargetSelector{ + { + includeDependents: true, + namePattern: "project-2", + }, + }, + []string{"project-1", "project-2", "project-0"}, + }, + { + "select dependents excluding package itself", + []*TargetSelector{ + { + excludeSelf: true, + includeDependents: true, + namePattern: "project-2", + }, + }, + []string{"project-0", "project-1"}, + }, + { + "filter using two selectors: one selects dependencies another selects dependents", + []*TargetSelector{ + { + excludeSelf: true, + includeDependents: true, + namePattern: "project-2", + }, + { + excludeSelf: true, + includeDependencies: true, + namePattern: "project-1", + }, + }, + []string{"project-0", "project-1", "project-2", "project-4"}, + }, + { + "select just a package by name", + []*TargetSelector{ + { + namePattern: "project-2", + }, + }, + []string{"project-2"}, + }, + // Note: we don't support the option to switch path prefix mode + // { + // "select by parentDir", + // []*TargetSelector{ + // { + // parentDir: "/packages", + // }, + // }, + // []string{"project-0", "project-1"}, + // }, + { + "select by parentDir using glob", + []*TargetSelector{ + { + parentDir: filepath.Join(root, "/packages/*"), + }, + }, + []string{"project-0", "project-1"}, + }, + { + "select by parentDir using globstar", + []*TargetSelector{ + { + parentDir: filepath.Join(root, "/project-5/**"), + }, + }, + []string{"project-5", "project-6"}, + }, + { + "select by parentDir with no glob", + []*TargetSelector{ + { + parentDir: filepath.Join(root, "/project-5"), + }, + }, + []string{"project-5"}, + }, + { + "select all packages except one", + []*TargetSelector{ + { + exclude: true, + namePattern: "project-1", + }, + }, + []string{"project-0", "project-2", "project-3", "project-4", "project-5", "project-6"}, + }, + { + "select by parentDir and exclude one package by pattern", + []*TargetSelector{ + { + parentDir: filepath.Join(root, "/packages/*"), + }, + { + exclude: true, + namePattern: "*-1", + }, + }, + []string{"project-0"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + pkgs, err := r.GetFilteredPackages(tc.Selectors) + if err != nil { + t.Fatalf("%v failed to filter packages: %v", tc.Name, err) + } + setMatches(t, tc.Name, pkgs.pkgs, tc.Expected) + }) + } + + t.Run("report unmatched filters", func(t *testing.T) { + pkgs, err := r.GetFilteredPackages([]*TargetSelector{ + { + excludeSelf: true, + includeDependencies: true, + namePattern: "project-7", + }, + }) + if err != nil { + t.Fatalf("unmatched filter failed to filter packages: %v", err) + } + if pkgs.pkgs.Len() != 0 { + t.Errorf("unmatched filter expected no packages, got %v", strings.Join(pkgs.pkgs.UnsafeListOfStrings(), ", ")) + } + if len(pkgs.unusedFilters) != 1 { + t.Errorf("unmatched filter expected to report one unused filter, got %v", len(pkgs.unusedFilters)) + } + }) +} + +func Test_matchScopedPackage(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + packageJSONs := make(map[interface{}]*fs.PackageJSON) + graph := &dag.AcyclicGraph{} + graph.Add("@foo/bar") + packageJSONs["@foo/bar"] = &fs.PackageJSON{ + Name: "@foo/bar", + Dir: filepath.Join(root, "packages", "bar"), + } + r := &Resolver{ + Graph: graph, + PackageInfos: packageJSONs, + Cwd: root, + } + pkgs, err := r.GetFilteredPackages([]*TargetSelector{ + { + namePattern: "bar", + }, + }) + if err != nil { + t.Fatalf("failed to filter packages: %v", err) + } + setMatches(t, "match scoped package", pkgs.pkgs, []string{"@foo/bar"}) +} + +func Test_matchExactPackages(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + packageJSONs := make(map[interface{}]*fs.PackageJSON) + graph := &dag.AcyclicGraph{} + graph.Add("@foo/bar") + packageJSONs["@foo/bar"] = &fs.PackageJSON{ + Name: "@foo/bar", + Dir: filepath.Join(root, "packages", "@foo", "bar"), + } + graph.Add("bar") + packageJSONs["bar"] = &fs.PackageJSON{ + Name: "bar", + Dir: filepath.Join(root, "packages", "bar"), + } + r := &Resolver{ + Graph: graph, + PackageInfos: packageJSONs, + Cwd: root, + } + pkgs, err := r.GetFilteredPackages([]*TargetSelector{ + { + namePattern: "bar", + }, + }) + if err != nil { + t.Fatalf("failed to filter packages: %v", err) + } + setMatches(t, "match exact package", pkgs.pkgs, []string{"bar"}) +} + +func Test_matchMultipleScopedPackages(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + packageJSONs := make(map[interface{}]*fs.PackageJSON) + graph := &dag.AcyclicGraph{} + graph.Add("@foo/bar") + packageJSONs["@foo/bar"] = &fs.PackageJSON{ + Name: "@foo/bar", + Dir: filepath.Join(root, "packages", "@foo", "bar"), + } + graph.Add("@types/bar") + packageJSONs["@types/bar"] = &fs.PackageJSON{ + Name: "@types/bar", + Dir: filepath.Join(root, "packages", "@types", "bar"), + } + r := &Resolver{ + Graph: graph, + PackageInfos: packageJSONs, + Cwd: root, + } + pkgs, err := r.GetFilteredPackages([]*TargetSelector{ + { + namePattern: "bar", + }, + }) + if err != nil { + t.Fatalf("failed to filter packages: %v", err) + } + setMatches(t, "match nothing with multiple scoped packages", pkgs.pkgs, []string{}) +} + +func Test_SCM(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + changedPackages := make(util.Set) + changedPackages.Add("package-1") + changedPackages.Add("package-2") + packageJSONs := make(map[interface{}]*fs.PackageJSON) + graph := &dag.AcyclicGraph{} + graph.Add("package-1") + packageJSONs["package-1"] = &fs.PackageJSON{ + Name: "package-1", + Dir: "package-1", + } + graph.Add("package-2") + packageJSONs["package-2"] = &fs.PackageJSON{ + Name: "package-2", + Dir: "package-2", + } + graph.Add("package-3") + packageJSONs["package-3"] = &fs.PackageJSON{ + Name: "package-3", + Dir: "package-3", + } + graph.Add("package-20") + packageJSONs["package-20"] = &fs.PackageJSON{ + Name: "package-20", + Dir: "package-20", + } + + graph.Connect(dag.BasicEdge("package-3", "package-20")) + + r := &Resolver{ + Graph: graph, + PackageInfos: packageJSONs, + Cwd: root, + PackagesChangedSince: func(since string) (util.Set, error) { + return changedPackages, nil + }, + } + + testCases := []struct { + Name string + Selectors []*TargetSelector + Expected []string + }{ + { + "all changed packages", + []*TargetSelector{ + { + diff: "HEAD~1", + }, + }, + []string{"package-1", "package-2"}, + }, + { + "changed packages in directory", + []*TargetSelector{ + { + diff: "HEAD~1", + parentDir: filepath.Join(root, "package-2"), + }, + }, + []string{"package-2"}, + }, + { + "changed packages matching pattern", + []*TargetSelector{ + { + diff: "HEAD~1", + namePattern: "package-2*", + }, + }, + []string{"package-2"}, + }, + { + "changed packages matching pattern", + []*TargetSelector{ + { + diff: "HEAD~1", + namePattern: "package-2*", + }, + }, + []string{"package-2"}, + }, + // Note: missing test here that takes advantage of automatically exempting + // test-only changes from pulling in dependents + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + pkgs, err := r.GetFilteredPackages(tc.Selectors) + if err != nil { + t.Fatalf("%v failed to filter packages: %v", tc.Name, err) + } + setMatches(t, tc.Name, pkgs.pkgs, tc.Expected) + }) + } +} diff --git a/cli/internal/scope/filter/matcher.go b/cli/internal/scope/filter/matcher.go new file mode 100644 index 0000000000000..2460326619b74 --- /dev/null +++ b/cli/internal/scope/filter/matcher.go @@ -0,0 +1,32 @@ +package filter + +import ( + "regexp" + "strings" + + "github.com/pkg/errors" +) + +type Matcher = func(pkgName string) bool + +func matchAll(pkgName string) bool { + return true +} + +func matcherFromPattern(pattern string) (Matcher, error) { + if pattern == "*" { + return matchAll, nil + } + + escaped := regexp.QuoteMeta(pattern) + // replace escaped '*' with regex '.*' + normalized := strings.ReplaceAll(escaped, "\\*", ".*") + if normalized == pattern { + return func(pkgName string) bool { return pkgName == pattern }, nil + } + regex, err := regexp.Compile("^" + normalized + "$") + if err != nil { + return nil, errors.Wrapf(err, "failed to compile filter pattern to regex: %v", pattern) + } + return func(pkgName string) bool { return regex.Match([]byte(pkgName)) }, nil +} diff --git a/cli/internal/scope/filter/matcher_test.go b/cli/internal/scope/filter/matcher_test.go new file mode 100644 index 0000000000000..966be2b8abfe6 --- /dev/null +++ b/cli/internal/scope/filter/matcher_test.go @@ -0,0 +1,65 @@ +package filter + +import "testing" + +func TestMatcher(t *testing.T) { + testCases := map[string][]struct { + test string + want bool + }{ + "*": { + { + test: "@eslint/plugin-foo", + want: true, + }, + { + test: "express", + want: true, + }, + }, + "eslint-*": { + { + test: "eslint-plugin-foo", + want: true, + }, + { + test: "express", + want: false, + }, + }, + "*plugin*": { + { + test: "@eslint/plugin-foo", + want: true, + }, + { + test: "express", + want: false, + }, + }, + "a*c": { + { + test: "abc", + want: true, + }, + }, + "*-positive": { + { + test: "is-positive", + want: true, + }, + }, + } + for pattern, tests := range testCases { + matcher, err := matcherFromPattern(pattern) + if err != nil { + t.Fatalf("failed to compile match pattern %v, %v", pattern, err) + } + for _, testCase := range tests { + got := matcher(testCase.test) + if got != testCase.want { + t.Errorf("%v.match(%v) got %v, want %v", pattern, testCase.test, got, testCase.want) + } + } + } +} diff --git a/cli/internal/scope/filter/parse_target_selector.go b/cli/internal/scope/filter/parse_target_selector.go new file mode 100644 index 0000000000000..3837c0df21c3e --- /dev/null +++ b/cli/internal/scope/filter/parse_target_selector.go @@ -0,0 +1,140 @@ +package filter + +import ( + "errors" + "path/filepath" + "regexp" + "strings" +) + +type TargetSelector struct { + includeDependencies bool + matchDependencies bool + includeDependents bool + exclude bool + excludeSelf bool + followProdDepsOnly bool + parentDir string + namePattern string + diff string + raw string +} + +func (ts *TargetSelector) IsValid() bool { + return ts.diff != "" || ts.parentDir != "" || ts.namePattern != "" +} + +var errCantMatchDependencies = errors.New("cannot use match dependencies without specifying either a directory or package") + +// ParseTargetSelector is a function that returns PNPM compatible --filter command line flags +func ParseTargetSelector(rawSelector string, prefix string) (TargetSelector, error) { + exclude := false + firstChar := rawSelector[0] + selector := rawSelector + if firstChar == '!' { + selector = selector[1:] + exclude = true + } + excludeSelf := false + includeDependencies := strings.HasSuffix(selector, "...") + if includeDependencies { + selector = selector[:len(selector)-3] + if strings.HasSuffix(selector, "^") { + excludeSelf = true + selector = selector[:len(selector)-1] + } + } + includeDependents := strings.HasPrefix(selector, "...") + if includeDependents { + selector = selector[3:] + if strings.HasPrefix(selector, "^") { + excludeSelf = true + selector = selector[1:] + } + } + regex := regexp.MustCompile(`^([^.](?:[^{}[\]]*[^{}[\].])?)?(\{[^}]+\})?((?:\.{3})?\[[^\]]+\])?$`) + matches := regex.FindAllStringSubmatch(selector, -1) + + diff := "" + parentDir := "" + namePattern := "" + + if len(matches) == 0 { + if isSelectorByLocation(selector) { + return TargetSelector{ + diff: diff, + exclude: exclude, + excludeSelf: false, + includeDependencies: includeDependencies, + includeDependents: includeDependents, + namePattern: namePattern, + parentDir: filepath.Join(prefix, selector), + raw: rawSelector, + }, nil + } + return TargetSelector{ + diff: diff, + exclude: exclude, + excludeSelf: excludeSelf, + includeDependencies: includeDependencies, + includeDependents: includeDependents, + namePattern: selector, + parentDir: parentDir, + raw: rawSelector, + }, nil + } + + preAddDepdencies := false + if len(matches) > 0 && len(matches[0]) > 0 { + if len(matches[0][1]) > 0 { + namePattern = matches[0][1] + } + if len(matches[0][2]) > 0 { + parentDir = matches[0][2] + parentDir = filepath.Join(prefix, parentDir[1:len(parentDir)-1]) + } + if len(matches[0][3]) > 0 { + diff = matches[0][3] + if strings.HasPrefix(diff, "...") { + if parentDir == "" && namePattern == "" { + return TargetSelector{}, errCantMatchDependencies + } + preAddDepdencies = true + diff = diff[3:] + } + // strip [] + diff = diff[1 : len(diff)-1] + } + } + + return TargetSelector{ + diff: diff, + exclude: exclude, + excludeSelf: excludeSelf, + includeDependencies: includeDependencies, + matchDependencies: preAddDepdencies, + includeDependents: includeDependents, + namePattern: namePattern, + parentDir: parentDir, + raw: rawSelector, + }, nil +} + +// isSelectorByLocation returns true if the selector is by filesystem location +func isSelectorByLocation(rawSelector string) bool { + if rawSelector[0:1] != "." { + return false + } + + // . or ./ or .\ + if len(rawSelector) == 1 || rawSelector[1:2] == "/" || rawSelector[1:2] == "\\" { + return true + } + + if rawSelector[1:2] != "." { + return false + } + + // .. or ../ or ..\ + return len(rawSelector) == 2 || rawSelector[2:3] == "/" || rawSelector[2:3] == "\\" +} diff --git a/cli/internal/scope/filter/parse_target_selector_test.go b/cli/internal/scope/filter/parse_target_selector_test.go new file mode 100644 index 0000000000000..b1f95c6517d1f --- /dev/null +++ b/cli/internal/scope/filter/parse_target_selector_test.go @@ -0,0 +1,313 @@ +package filter + +import ( + "path/filepath" + "reflect" + "testing" +) + +func TestParseTargetSelector(t *testing.T) { + type args struct { + rawSelector string + prefix string + } + tests := []struct { + name string + args args + want TargetSelector + wantErr bool + }{ + { + "foo", + args{"foo", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "foo", + parentDir: "", + }, + false, + }, + { + "foo...", + args{"foo...", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: true, + includeDependents: false, + namePattern: "foo", + parentDir: "", + }, + false, + }, + { + "...foo", + args{"...foo", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: true, + namePattern: "foo", + parentDir: "", + }, + false, + }, + { + "...foo...", + args{"...foo...", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: true, + includeDependents: true, + namePattern: "foo", + parentDir: "", + }, + false, + }, + { + "foo^...", + args{"foo^...", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: true, + includeDependencies: true, + includeDependents: false, + namePattern: "foo", + parentDir: "", + }, + false, + }, + { + "...^foo", + args{"...^foo", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: true, + includeDependencies: false, + includeDependents: true, + namePattern: "foo", + parentDir: "", + }, + false, + }, + { + "./foo", + args{"./foo", "./"}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "", + parentDir: "foo", + }, + false, + }, + { + "../foo", + args{"../foo", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "", + parentDir: filepath.FromSlash("../foo"), + }, + false, + }, + { + "...{./foo}", + args{"...{./foo}", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: true, + namePattern: "", + parentDir: "foo", + }, + false, + }, + { + ".", + args{".", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "", + parentDir: ".", + }, + false, + }, + { + "..", + args{"..", "."}, + TargetSelector{ + diff: "", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "", + parentDir: "..", + }, + false, + }, + { + "[master]", + args{"[master]", "."}, + TargetSelector{ + diff: "master", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "", + parentDir: "", + }, + false, + }, + { + "{foo}[master]", + args{"{foo}[master]", "."}, + TargetSelector{ + diff: "master", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "", + parentDir: "foo", + }, + false, + }, + { + "pattern{foo}[master]", + args{"pattern{foo}[master]", "."}, + TargetSelector{ + diff: "master", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: false, + namePattern: "pattern", + parentDir: "foo", + }, + false, + }, + { + "[master]...", + args{"[master]...", "."}, + TargetSelector{ + diff: "master", + exclude: false, + excludeSelf: false, + includeDependencies: true, + includeDependents: false, + namePattern: "", + parentDir: "", + }, + false, + }, + { + "...[master]", + args{"...[master]", "."}, + TargetSelector{ + diff: "master", + exclude: false, + excludeSelf: false, + includeDependencies: false, + includeDependents: true, + namePattern: "", + parentDir: "", + }, + false, + }, + { + "...[master]...", + args{"...[master]...", "."}, + TargetSelector{ + diff: "master", + exclude: false, + excludeSelf: false, + includeDependencies: true, + includeDependents: true, + namePattern: "", + parentDir: "", + }, + false, + }, + { + "foo...[master]", + args{"foo...[master]", "."}, + TargetSelector{ + diff: "master", + namePattern: "foo", + matchDependencies: true, + }, + false, + }, + { + "foo...[master]...", + args{"foo...[master]...", "."}, + TargetSelector{ + diff: "master", + namePattern: "foo", + matchDependencies: true, + includeDependencies: true, + }, + false, + }, + { + "{foo}...[master]", + args{"{foo}...[master]", "."}, + TargetSelector{ + diff: "master", + parentDir: "foo", + matchDependencies: true, + }, + false, + }, + { + "......[master]", + args{"......[master]", "."}, + TargetSelector{}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTargetSelector(tt.args.rawSelector, tt.args.prefix) + if tt.wantErr { + if err == nil { + t.Errorf("ParseTargetSelector() error = %#v, wantErr %#v", err, tt.wantErr) + } + } else { + // copy the raw selector from the args into what we want. This value is used + // for reporting errors in the case of a malformed selector + tt.want.raw = tt.args.rawSelector + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseTargetSelector() = %#v, want %#v", got, tt.want) + } + } + }) + } +} diff --git a/cli/internal/scope/scope.go b/cli/internal/scope/scope.go index bd7ee9434e082..2afcd113f8777 100644 --- a/cli/internal/scope/scope.go +++ b/cli/internal/scope/scope.go @@ -10,7 +10,7 @@ import ( "github.com/vercel/turborepo/cli/internal/context" "github.com/vercel/turborepo/cli/internal/fs" "github.com/vercel/turborepo/cli/internal/scm" - "github.com/vercel/turborepo/cli/internal/ui" + scope_filter "github.com/vercel/turborepo/cli/internal/scope/filter" "github.com/vercel/turborepo/cli/internal/util" "github.com/vercel/turborepo/cli/internal/util/filter" ) @@ -23,138 +23,96 @@ type Opts struct { Cwd string IgnorePatterns []string GlobalDepPatterns []string + FilterPatterns []string } -func ResolvePackages(opts *Opts, scm scm.SCM, ctx *context.Context, tui cli.Ui, logger hclog.Logger) (util.Set, error) { - changedFiles, err := getChangedFiles(opts, scm) - if err != nil { - return nil, err +// asFilterPatterns normalizes legacy selectors to filter syntax +func (o *Opts) asFilterPatterns() []string { + patterns := make([]string, len(o.FilterPatterns)) + copy(patterns, o.FilterPatterns) + prefix := "" + if o.IncludeDependents { + prefix = "..." + } + suffix := "" + if o.IncludeDependencies { + suffix = "..." + } + since := "" + if o.Since != "" { + since = fmt.Sprintf("[%v]", o.Since) + } + if len(o.Patterns) > 0 { + // --scope implies our tweaked syntax to see if any dependency matches + since = "..." + since + for _, pattern := range o.Patterns { + if strings.HasPrefix(pattern, "!") { + patterns = append(patterns, pattern) + } else { + filterPattern := fmt.Sprintf("%v%v%v%v", prefix, pattern, since, suffix) + patterns = append(patterns, filterPattern) + } + } + } else if since != "" { + // no scopes specified, but --since was provided + filterPattern := fmt.Sprintf("%v%v%v", prefix, since, suffix) + patterns = append(patterns, filterPattern) } + return patterns +} - // Note that we do this calculation *before* filtering the changed files. - // The user can technically specify both that a file is a global dependency and - // that it should be ignored, and currently we treat a change to that file as a - // global change. - hasRepoGlobalFileChanged, err := repoGlobalFileHasChanged(opts, changedFiles) - if err != nil { - return nil, err - } - filteredChangedFiles, err := filterIgnoredFiles(opts, changedFiles) +func ResolvePackages(opts *Opts, scm scm.SCM, ctx *context.Context, tui cli.Ui, logger hclog.Logger) (util.Set, error) { + filterResolver := &scope_filter.Resolver{ + Graph: &ctx.TopologicalGraph, + PackageInfos: ctx.PackageInfos, + Cwd: opts.Cwd, + PackagesChangedSince: opts.getPackageChangeFunc(scm, ctx.PackageInfos), + } + filterPatterns := opts.asFilterPatterns() + filteredPkgs, err := filterResolver.GetPackagesFromPatterns(filterPatterns) if err != nil { return nil, err } - changedPackages := make(util.Set) - // Be specific with the changed packages only if no repo-wide changes occurred - if !hasRepoGlobalFileChanged { - changedPackages = getChangedPackages(filteredChangedFiles, ctx.PackageInfos) - } - - // Scoped packages - // Unwind scope globs - scopePkgs, err := getScopedPackages(ctx.PackageNames, opts.Patterns) - if err != nil { - return nil, errors.Wrap(err, "invalid scope") - } - - // Filter Packages - filteredPkgs := make(util.Set) - includeDependencies := opts.IncludeDependencies - includeDependents := opts.IncludeDependents - // If there has been a global change, run everything in scope - // (this may be every package if no scope is provider) - if hasRepoGlobalFileChanged { - // If a global dependency has changed, run everything in scope. - // If no scope was provided, run everything - if scopePkgs.Len() > 0 { - filteredPkgs = scopePkgs - } else { - for _, f := range ctx.PackageNames { - filteredPkgs.Add(f) - } - } - } else if scopePkgs.Len() > 0 && changedPackages.Len() > 0 { - // If we have both a scope and changed packages: - // We want the intersection of two sets: - // 1. the scopes and all of their dependencies - // 2. the changed packages and all of their dependents - // - // Note that other commandline flags can cause including dependents / dependencies - // beyond this set - - // scopes and all deps - rootsAndDeps := make(util.Set) - for _, pkg := range scopePkgs { - rootsAndDeps.Add(pkg) - deps, err := ctx.TopologicalGraph.Ancestors(pkg) - if err != nil { - return nil, err - } - for _, dep := range deps { - rootsAndDeps.Add(dep) - } - } - - // changed packages and all dependents - for _, pkg := range changedPackages { - // do the intersection inline, rather than building up the set - if rootsAndDeps.Includes(pkg) { - filteredPkgs.Add(pkg) - } - dependents, err := ctx.TopologicalGraph.Descendents(pkg) - if err != nil { - return nil, err - } - for _, dependent := range dependents { - if rootsAndDeps.Includes(dependent) { - filteredPkgs.Add(dependent) - } - } - } - } else if changedPackages.Len() > 0 { - // --since was specified, there are changes, but no scope was specified. - // Run the packages that have changed - filteredPkgs = changedPackages - } else if scopePkgs.Len() > 0 { - // There was either a global change, or no changes, or no --since flag - // There was a --scope flag, run the desired scopes - filteredPkgs = scopePkgs - } else if opts.Since == "" { - // No scope was specified, and no diff base was specified - // Run every package + if len(filterPatterns) == 0 { + // no filters specified, run every package for _, f := range ctx.PackageNames { filteredPkgs.Add(f) } } + filteredPkgs.Delete(ctx.RootNode) + return filteredPkgs, nil +} - if includeDependents { - // TODO(gsoltis): we're concurrently iterating and adding to a map, potentially - // resulting in a bunch of duplicate work as we look for descendents of something - // that has already had all of its descendents included. - for _, pkg := range filteredPkgs { - err = addDependents(filteredPkgs, pkg, ctx, logger) - if err != nil { - return nil, err - } +func (o *Opts) getPackageChangeFunc(scm scm.SCM, packageInfos map[interface{}]*fs.PackageJSON) scope_filter.PackagesChangedSince { + return func(since string) (util.Set, error) { + // We could filter changed files at the git level, since it's possible + // that the changes we're interested in are scoped, but we need to handle + // global dependencies changing as well. A future optimization might be to + // scope changed files more deeply if we know there are no global dependencies. + changedFiles, err := getChangedFiles(o, scm) + if err != nil { + return nil, err } - logger.Debug("running with dependents") - } - - if includeDependencies { - // TODO(gsoltis): we're concurrently iterating and adding to a map, potentially - // resulting in a bunch of duplicate work as we look for dependencies of something - // that has already had all of its dependencies included. - for _, pkg := range filteredPkgs { - err = addDependencies(filteredPkgs, pkg, ctx, logger) - if err != nil { - return nil, err + if hasRepoGlobalFileChanged, err := repoGlobalFileHasChanged(o, changedFiles); err != nil { + return nil, err + } else if hasRepoGlobalFileChanged { + allPkgs := make(util.Set) + for pkg := range packageInfos { + allPkgs.Add(pkg) } + return allPkgs, nil + } + filteredChangedFiles, err := filterIgnoredFiles(o, changedFiles) + if err != nil { + return nil, err } - logger.Debug(ui.Dim("running with dependencies")) + changedPkgs := getChangedPackages(filteredChangedFiles, packageInfos) + return changedPkgs, nil } - return filteredPkgs, nil } +// getChangedFiles returns platform-dependent paths relative to the root of the monorepo func getChangedFiles(opts *Opts, scm scm.SCM) ([]string, error) { if opts.Since == "" { return []string{}, nil @@ -178,98 +136,38 @@ func repoGlobalFileHasChanged(opts *Opts, changedFiles []string) (bool, error) { return false, nil } -func filterIgnoredFiles(opts *Opts, changedFiles []string) (util.Set, error) { +func filterIgnoredFiles(opts *Opts, changedFiles []string) ([]string, error) { ignoreGlob, err := filter.Compile(opts.IgnorePatterns) if err != nil { return nil, errors.Wrap(err, "invalid ignore globs") } - filteredChanges := make(util.Set) + filteredChanges := []string{} for _, file := range changedFiles { // If we don't have anything to ignore, or if this file doesn't match the ignore pattern, // keep it as a changed file. if ignoreGlob == nil || !ignoreGlob.Match(file) { - filteredChanges.Add(file) + filteredChanges = append(filteredChanges, file) } } return filteredChanges, nil } -func getChangedPackages(changedFiles util.Set, packageInfos map[interface{}]*fs.PackageJSON) util.Set { +func getChangedPackages(changedFiles []string, packageInfos map[interface{}]*fs.PackageJSON) util.Set { changedPackages := make(util.Set) for k, pkgInfo := range packageInfos { partialPath := pkgInfo.Dir - if changedFiles.Some(func(v interface{}) bool { - return strings.HasPrefix(fmt.Sprintf("%v", v), partialPath) // true - }) { + if someFileHasPrefix(partialPath, changedFiles) { changedPackages.Add(k) } } return changedPackages } -func addDependents(deps util.Set, pkg interface{}, ctx *context.Context, logger hclog.Logger) error { - descenders, err := ctx.TopologicalGraph.Descendents(pkg) - if err != nil { - return errors.Wrap(err, "error calculating affected packages") - } - logger.Debug("dependents", "pkg", pkg, "value", descenders.List()) - for _, d := range descenders { - // we need to exclude the fake root node - // since it is not a real package - if d != ctx.RootNode { - deps.Add(d) +func someFileHasPrefix(prefix string, files []string) bool { + for _, f := range files { + if strings.HasPrefix(f, prefix) { + return true } } - return nil -} - -func addDependencies(deps util.Set, pkg interface{}, ctx *context.Context, logger hclog.Logger) error { - ancestors, err := ctx.TopologicalGraph.Ancestors(pkg) - if err != nil { - return errors.Wrap(err, "error getting dependency") - } - logger.Debug("dependencies", "pkg", pkg, "value", ancestors.List()) - for _, d := range ancestors { - // we need to exclude the fake root node - // since it is not a real package - if d != ctx.RootNode { - deps.Add(d) - } - } - return nil -} - -// getScopedPackages returns a set of package names in scope for a given list of glob patterns -func getScopedPackages(packageNames []string, scopePatterns []string) (util.Set, error) { - scopedPkgs := make(util.Set) - if len(scopePatterns) == 0 { - return scopedPkgs, nil - } - - include := make([]string, 0, len(scopePatterns)) - exclude := make([]string, 0, len(scopePatterns)) - - for _, pattern := range scopePatterns { - if strings.HasPrefix(pattern, "!") { - exclude = append(exclude, pattern[1:]) - } else { - include = append(include, pattern) - } - } - - glob, err := filter.NewIncludeExcludeFilter(include, exclude) - if err != nil { - return nil, err - } - for _, f := range packageNames { - if glob.Match(f) { - scopedPkgs.Add(f) - } - } - - if len(include) > 0 && scopedPkgs.Len() == 0 { - return nil, errors.Errorf("No packages found matching the provided scope pattern.") - } - - return scopedPkgs, nil + return false } diff --git a/cli/internal/scope/scope_test.go b/cli/internal/scope/scope_test.go index 7244cfa939f1c..562e2245ff5ec 100644 --- a/cli/internal/scope/scope_test.go +++ b/cli/internal/scope/scope_test.go @@ -8,62 +8,12 @@ import ( "github.com/hashicorp/go-hclog" "github.com/pyr-sh/dag" - "github.com/stretchr/testify/assert" "github.com/vercel/turborepo/cli/internal/context" "github.com/vercel/turborepo/cli/internal/fs" "github.com/vercel/turborepo/cli/internal/ui" "github.com/vercel/turborepo/cli/internal/util" ) -func TestScopedPackages(t *testing.T) { - cases := []struct { - Name string - PackageNames []string - Pattern []string - Expected util.Set - }{ - { - "starts with @", - []string{"@sample/app", "sample-app", "jared"}, - []string{"@sample/*"}, - util.Set{"@sample/app": "@sample/app"}, - }, - { - "return an array of matches", - []string{"foo", "bar", "baz"}, - []string{"f*"}, - util.Set{"foo": "foo"}, - }, - { - "return an array of matches", - []string{"foo", "bar", "baz"}, - []string{"f*", "bar"}, - util.Set{"bar": "bar", "foo": "foo"}, - }, - { - "return matches in the order the list were defined", - []string{"foo", "bar", "baz"}, - []string{"*a*", "!f*"}, - util.Set{"bar": "bar", "baz": "baz"}, - }, - } - - for i, tc := range cases { - t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { - actual, err := getScopedPackages(tc.PackageNames, tc.Pattern) - if err != nil { - t.Fatalf("invalid scope parse: %#v", err) - } - assert.EqualValues(t, tc.Expected, actual) - }) - } - - t.Run(fmt.Sprintf("%d-%s", len(cases), "throws an error if no package matches the provided scope pattern"), func(t *testing.T) { - _, err := getScopedPackages([]string{"foo", "bar"}, []string{"baz"}) - assert.Error(t, err) - }) -} - type mockSCM struct { changed []string } @@ -178,11 +128,13 @@ func TestResolvePackages(t *testing.T) { includeDependencies: true, // scope implies include-dependencies }, { - // a dependent lib changed, user explicitly asked to not build dependencies + // a dependent lib changed, user explicitly asked to not build dependencies. + // Since the package matching the scope had a changed dependency, we run it. + // We don't include its dependencies because the user asked for no dependencies. // note: this is not yet supported by the CLI, as you cannot specify --include-dependencies=false name: "dependency of scope changed, user asked to not include depedencies", changed: []string{"libs/libA/src/index.ts"}, - expected: []string{"libA", "app1"}, + expected: []string{"app1"}, since: "dummy", scope: []string{"app1"}, includeDependencies: false, @@ -192,7 +144,7 @@ func TestResolvePackages(t *testing.T) { // note: this is not yet supported by the CLI, as you cannot specify --include-dependencies=false name: "nested dependency of scope changed, user asked to not include dependencies", changed: []string{"libs/libB/src/index.ts"}, - expected: []string{"libA", "libB", "app1"}, + expected: []string{"app1"}, since: "dummy", scope: []string{"app1"}, includeDependencies: false,