diff options
| author | 2023-04-28 01:36:44 +0800 | |
|---|---|---|
| committer | 2023-04-28 01:36:44 +0800 | |
| commit | dd84b9d64fb98746a230cd24233ff50a562c39c9 (patch) | |
| tree | b583261ef00b3afe72ec4d6dacb31e57779a6faf /cli/internal/scope/scope.go | |
| parent | 0b46fcd72ac34382387b2bcf9095233efbcc52f4 (diff) | |
| download | HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.tar.gz HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.zip | |
Diffstat (limited to 'cli/internal/scope/scope.go')
| -rw-r--r-- | cli/internal/scope/scope.go | 380 |
1 files changed, 380 insertions, 0 deletions
diff --git a/cli/internal/scope/scope.go b/cli/internal/scope/scope.go new file mode 100644 index 0000000..b5ed4e7 --- /dev/null +++ b/cli/internal/scope/scope.go @@ -0,0 +1,380 @@ +package scope + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/hashicorp/go-hclog" + "github.com/mitchellh/cli" + "github.com/pkg/errors" + "github.com/vercel/turbo/cli/internal/context" + "github.com/vercel/turbo/cli/internal/lockfile" + "github.com/vercel/turbo/cli/internal/scm" + scope_filter "github.com/vercel/turbo/cli/internal/scope/filter" + "github.com/vercel/turbo/cli/internal/turbopath" + "github.com/vercel/turbo/cli/internal/turbostate" + "github.com/vercel/turbo/cli/internal/util" + "github.com/vercel/turbo/cli/internal/util/filter" + "github.com/vercel/turbo/cli/internal/workspace" +) + +// LegacyFilter holds the options in use before the filter syntax. They have their own rules +// for how they are compiled into filter expressions. +type LegacyFilter struct { + // IncludeDependencies is whether to include pkg.dependencies in execution (defaults to false) + IncludeDependencies bool + // SkipDependents is whether to skip dependent impacted consumers in execution (defaults to false) + SkipDependents bool + // Entrypoints is a list of package entrypoints + Entrypoints []string + // Since is the git ref used to calculate changed packages + Since string +} + +var _sinceHelp = `Limit/Set scope to changed packages since a +mergebase. This uses the git diff ${target_branch}... +mechanism to identify which packages have changed.` + +func addLegacyFlagsFromArgs(opts *LegacyFilter, args *turbostate.ParsedArgsFromRust) { + opts.IncludeDependencies = args.Command.Run.IncludeDependencies + opts.SkipDependents = args.Command.Run.NoDeps + opts.Entrypoints = args.Command.Run.Scope + opts.Since = args.Command.Run.Since +} + +// Opts holds the options for how to select the entrypoint packages for a turbo run +type Opts struct { + LegacyFilter LegacyFilter + // IgnorePatterns is the list of globs of file paths to ignore from execution scope calculation + IgnorePatterns []string + // GlobalDepPatterns is a list of globs to global files whose contents will be included in the global hash calculation + GlobalDepPatterns []string + // Patterns are the filter patterns supplied to --filter on the commandline + FilterPatterns []string + + PackageInferenceRoot turbopath.RelativeSystemPath +} + +var ( + _filterHelp = `Use the given selector to specify package(s) to act as +entry points. The syntax mirrors pnpm's syntax, and +additional documentation and examples can be found in +turbo's documentation https://turbo.build/repo/docs/reference/command-line-reference#--filter +--filter can be specified multiple times. Packages that +match any filter will be included.` + _ignoreHelp = `Files to ignore when calculating changed files (i.e. --since). Supports globs.` + _globalDepHelp = `Specify glob of global filesystem dependencies to be hashed. Useful for .env and files +in the root directory. Includes turbo.json, root package.json, and the root lockfile by default.` +) + +// normalize package inference path. We compare against "" in several places, so maintain +// that behavior. In a post-rust-port world, this should more properly be an Option +func resolvePackageInferencePath(raw string) (turbopath.RelativeSystemPath, error) { + pkgInferenceRoot, err := turbopath.CheckedToRelativeSystemPath(raw) + if err != nil { + return "", errors.Wrapf(err, "invalid package inference root %v", raw) + } + if pkgInferenceRoot == "." { + return "", nil + } + return pkgInferenceRoot, nil +} + +// OptsFromArgs adds the settings relevant to this package to the given Opts +func OptsFromArgs(opts *Opts, args *turbostate.ParsedArgsFromRust) error { + opts.FilterPatterns = args.Command.Run.Filter + opts.IgnorePatterns = args.Command.Run.Ignore + opts.GlobalDepPatterns = args.Command.Run.GlobalDeps + pkgInferenceRoot, err := resolvePackageInferencePath(args.Command.Run.PkgInferenceRoot) + if err != nil { + return err + } + opts.PackageInferenceRoot = pkgInferenceRoot + addLegacyFlagsFromArgs(&opts.LegacyFilter, args) + return nil +} + +// AsFilterPatterns normalizes legacy selectors to filter syntax +func (l *LegacyFilter) AsFilterPatterns() []string { + var patterns []string + prefix := "" + if !l.SkipDependents { + prefix = "..." + } + suffix := "" + if l.IncludeDependencies { + suffix = "..." + } + since := "" + if l.Since != "" { + since = fmt.Sprintf("[%v]", l.Since) + } + if len(l.Entrypoints) > 0 { + // --scope implies our tweaked syntax to see if any dependency matches + if since != "" { + since = "..." + since + } + for _, pattern := range l.Entrypoints { + 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 +} + +// ResolvePackages translates specified flags to a set of entry point packages for +// the selected tasks. Returns the selected packages and whether or not the selected +// packages represents a default "all packages". +func ResolvePackages(opts *Opts, repoRoot turbopath.AbsoluteSystemPath, scm scm.SCM, ctx *context.Context, tui cli.Ui, logger hclog.Logger) (util.Set, bool, error) { + inferenceBase, err := calculateInference(repoRoot, opts.PackageInferenceRoot, ctx.WorkspaceInfos, logger) + if err != nil { + return nil, false, err + } + filterResolver := &scope_filter.Resolver{ + Graph: &ctx.WorkspaceGraph, + WorkspaceInfos: ctx.WorkspaceInfos, + Cwd: repoRoot, + Inference: inferenceBase, + PackagesChangedInRange: opts.getPackageChangeFunc(scm, repoRoot, ctx), + } + filterPatterns := opts.FilterPatterns + legacyFilterPatterns := opts.LegacyFilter.AsFilterPatterns() + filterPatterns = append(filterPatterns, legacyFilterPatterns...) + isAllPackages := len(filterPatterns) == 0 && opts.PackageInferenceRoot == "" + filteredPkgs, err := filterResolver.GetPackagesFromPatterns(filterPatterns) + if err != nil { + return nil, false, err + } + + if isAllPackages { + // no filters specified, run every package + for _, f := range ctx.WorkspaceNames { + filteredPkgs.Add(f) + } + } + filteredPkgs.Delete(ctx.RootNode) + return filteredPkgs, isAllPackages, nil +} + +func calculateInference(repoRoot turbopath.AbsoluteSystemPath, pkgInferencePath turbopath.RelativeSystemPath, packageInfos workspace.Catalog, logger hclog.Logger) (*scope_filter.PackageInference, error) { + if pkgInferencePath == "" { + // No inference specified, no need to calculate anything + return nil, nil + } + logger.Debug(fmt.Sprintf("Using %v as a basis for selecting packages", pkgInferencePath)) + fullInferencePath := repoRoot.Join(pkgInferencePath) + for _, pkgInfo := range packageInfos.PackageJSONs { + pkgPath := pkgInfo.Dir.RestoreAnchor(repoRoot) + inferredPathIsBelow, err := pkgPath.ContainsPath(fullInferencePath) + if err != nil { + return nil, err + } + // We skip over the root package as the inferred path will always be below it + if inferredPathIsBelow && pkgPath != repoRoot { + // set both. The user might have set a parent directory filter, + // in which case we *should* fail to find any packages, but we should + // do so in a consistent manner + return &scope_filter.PackageInference{ + PackageName: pkgInfo.Name, + DirectoryRoot: pkgInferencePath, + }, nil + } + inferredPathIsBetweenRootAndPkg, err := fullInferencePath.ContainsPath(pkgPath) + if err != nil { + return nil, err + } + if inferredPathIsBetweenRootAndPkg { + // we've found *some* package below our inference directory. We can stop now and conclude + // that we're looking for all packages in a subdirectory + break + } + } + return &scope_filter.PackageInference{ + DirectoryRoot: pkgInferencePath, + }, nil +} + +func (o *Opts) getPackageChangeFunc(scm scm.SCM, cwd turbopath.AbsoluteSystemPath, ctx *context.Context) scope_filter.PackagesChangedInRange { + return func(fromRef string, toRef 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. + var changedFiles []string + if fromRef != "" { + scmChangedFiles, err := scm.ChangedFiles(fromRef, toRef, cwd.ToStringDuringMigration()) + if err != nil { + return nil, err + } + sort.Strings(scmChangedFiles) + changedFiles = scmChangedFiles + } + makeAllPkgs := func() util.Set { + allPkgs := make(util.Set) + for pkg := range ctx.WorkspaceInfos.PackageJSONs { + allPkgs.Add(pkg) + } + return allPkgs + } + if hasRepoGlobalFileChanged, err := repoGlobalFileHasChanged(o, getDefaultGlobalDeps(), changedFiles); err != nil { + return nil, err + } else if hasRepoGlobalFileChanged { + return makeAllPkgs(), nil + } + + filteredChangedFiles, err := filterIgnoredFiles(o, changedFiles) + if err != nil { + return nil, err + } + changedPkgs := getChangedPackages(filteredChangedFiles, ctx.WorkspaceInfos) + + if lockfileChanges, fullChanges := getChangesFromLockfile(scm, ctx, changedFiles, fromRef); !fullChanges { + for _, pkg := range lockfileChanges { + changedPkgs.Add(pkg) + } + } else { + return makeAllPkgs(), nil + } + + return changedPkgs, nil + } +} + +func getChangesFromLockfile(scm scm.SCM, ctx *context.Context, changedFiles []string, fromRef string) ([]string, bool) { + lockfileFilter, err := filter.Compile([]string{ctx.PackageManager.Lockfile}) + if err != nil { + panic(fmt.Sprintf("Lockfile is invalid glob: %v", err)) + } + match := false + for _, file := range changedFiles { + if lockfileFilter.Match(file) { + match = true + break + } + } + if !match { + return nil, false + } + + if lockfile.IsNil(ctx.Lockfile) { + return nil, true + } + + prevContents, err := scm.PreviousContent(fromRef, ctx.PackageManager.Lockfile) + if err != nil { + // unable to reconstruct old lockfile, assume everything changed + return nil, true + } + prevLockfile, err := ctx.PackageManager.UnmarshalLockfile(ctx.WorkspaceInfos.PackageJSONs[util.RootPkgName], prevContents) + if err != nil { + // unable to parse old lockfile, assume everything changed + return nil, true + } + additionalPkgs, err := ctx.ChangedPackages(prevLockfile) + if err != nil { + // missing at least one lockfile, assume everything changed + return nil, true + } + + return additionalPkgs, false +} + +func getDefaultGlobalDeps() []string { + // include turbo.json and root package.json as implicit global dependencies + defaultGlobalDeps := []string{ + "turbo.json", + "package.json", + } + return defaultGlobalDeps +} + +func repoGlobalFileHasChanged(opts *Opts, defaultGlobalDeps []string, changedFiles []string) (bool, error) { + globalDepsGlob, err := filter.Compile(append(opts.GlobalDepPatterns, defaultGlobalDeps...)) + if err != nil { + return false, errors.Wrap(err, "invalid global deps glob") + } + + if globalDepsGlob != nil { + for _, file := range changedFiles { + if globalDepsGlob.Match(filepath.ToSlash(file)) { + return true, nil + } + } + } + return false, nil +} + +func filterIgnoredFiles(opts *Opts, changedFiles []string) ([]string, error) { + // changedFiles is an array of repo-relative system paths. + // opts.IgnorePatterns is an array of unix-separator glob paths. + ignoreGlob, err := filter.Compile(opts.IgnorePatterns) + if err != nil { + return nil, errors.Wrap(err, "invalid ignore globs") + } + 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(filepath.ToSlash(file)) { + filteredChanges = append(filteredChanges, file) + } + } + return filteredChanges, nil +} + +func fileInPackage(changedFile string, packagePath string) bool { + // This whole method is basically this regex: /^.*\/?$/ + // The regex is more-expensive, so we don't do it. + + // If it has the prefix, it might be in the package. + if strings.HasPrefix(changedFile, packagePath) { + // Now we need to see if the prefix stopped at a reasonable boundary. + prefixLen := len(packagePath) + changedFileLen := len(changedFile) + + // Same path. + if prefixLen == changedFileLen { + return true + } + + // We know changedFile is longer than packagePath. + // We can safely directly index into it. + // Look ahead one byte and see if it's the separator. + if changedFile[prefixLen] == os.PathSeparator { + return true + } + } + + // If it does not have the prefix, it's definitely not in the package. + return false +} + +func getChangedPackages(changedFiles []string, packageInfos workspace.Catalog) util.Set { + changedPackages := make(util.Set) + for _, changedFile := range changedFiles { + found := false + for pkgName, pkgInfo := range packageInfos.PackageJSONs { + if pkgName != util.RootPkgName && fileInPackage(changedFile, pkgInfo.Dir.ToStringDuringMigration()) { + changedPackages.Add(pkgName) + found = true + break + } + } + if !found { + // Consider the root package to have changed + changedPackages.Add(util.RootPkgName) + } + } + return changedPackages +} |
