1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
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
}
|