// Copyright 2013 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:io' as io; import 'dart:math'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:yaml/yaml.dart'; import 'core.dart'; import 'git_version_finder.dart'; import 'process_runner.dart'; import 'repository_package.dart'; /// An entry in package enumeration for APIs that need to include extra /// data about the entry. class PackageEnumerationEntry { /// Creates a new entry for the given package. PackageEnumerationEntry(this.package, {required this.excluded}); /// The package this entry corresponds to. Be sure to check `excluded` before /// using this, as having an entry does not necessarily mean that the package /// should be included in the processing of the enumeration. final RepositoryPackage package; /// Whether or not this package was excluded by the command invocation. final bool excluded; } /// Interface definition for all commands in this tool. // TODO(stuartmorgan): Move most of this logic to PackageLoopingCommand. abstract class PluginCommand extends Command { /// Creates a command to operate on [packagesDir] with the given environment. PluginCommand( this.packagesDir, { this.processRunner = const ProcessRunner(), this.platform = const LocalPlatform(), GitDir? gitDir, }) : _gitDir = gitDir { argParser.addMultiOption( _packagesArg, splitCommas: true, help: 'Specifies which packages the command should run on (before sharding).\n', valueHelp: 'package1,package2,...', aliases: [_pluginsArg], ); argParser.addOption( _shardIndexArg, help: 'Specifies the zero-based index of the shard to ' 'which the command applies.', valueHelp: 'i', defaultsTo: '0', ); argParser.addOption( _shardCountArg, help: 'Specifies the number of shards into which plugins are divided.', valueHelp: 'n', defaultsTo: '1', ); argParser.addMultiOption( _excludeArg, abbr: 'e', help: 'A list of packages to exclude from from this command.\n\n' 'Alternately, a list of one or more YAML files that contain a list ' 'of packages to exclude.', defaultsTo: [], ); argParser.addFlag(_runOnChangedPackagesArg, help: 'Run the command on changed packages/plugins.\n' 'If no packages have changed, or if there have been changes that may\n' 'affect all packages, the command runs on all packages.\n' 'Packages excluded with $_excludeArg are excluded even if changed.\n' 'See $_baseShaArg if a custom base is needed to determine the diff.\n\n' 'Cannot be combined with $_packagesArg.\n'); argParser.addFlag(_runOnDirtyPackagesArg, help: 'Run the command on packages with changes that have not been committed.\n' 'Packages excluded with $_excludeArg are excluded even if changed.\n' 'Cannot be combined with $_packagesArg.\n', hide: true); argParser.addFlag(_packagesForBranchArg, help: 'This runs on all packages (equivalent to no package selection flag)\n' 'on main (or master), and behaves like --run-on-changed-packages on ' 'any other branch.\n\n' 'Cannot be combined with $_packagesArg.\n\n' 'This is intended for use in CI.\n', hide: true); argParser.addOption(_baseShaArg, help: 'The base sha used to determine git diff. \n' 'This is useful when $_runOnChangedPackagesArg is specified.\n' 'If not specified, merge-base is used as base sha.'); argParser.addFlag(_logTimingArg, help: 'Logs timing information.\n\n' 'Currently only logs per-package timing for multi-package commands, ' 'but more information may be added in the future.'); } static const String _baseShaArg = 'base-sha'; static const String _excludeArg = 'exclude'; static const String _logTimingArg = 'log-timing'; static const String _packagesArg = 'packages'; static const String _packagesForBranchArg = 'packages-for-branch'; static const String _pluginsArg = 'plugins'; static const String _runOnChangedPackagesArg = 'run-on-changed-packages'; static const String _runOnDirtyPackagesArg = 'run-on-dirty-packages'; static const String _shardCountArg = 'shardCount'; static const String _shardIndexArg = 'shardIndex'; /// The directory containing the plugin packages. final Directory packagesDir; /// The process runner. /// /// This can be overridden for testing. final ProcessRunner processRunner; /// The current platform. /// /// This can be overridden for testing. final Platform platform; /// The git directory to use. If unset, [gitDir] populates it from the /// packages directory's enclosing repository. /// /// This can be mocked for testing. GitDir? _gitDir; int? _shardIndex; int? _shardCount; // Cached set of explicitly excluded packages. Set? _excludedPackages; /// A context that matches the default for [platform]. p.Context get path => platform.isWindows ? p.windows : p.posix; /// The command to use when running `flutter`. String get flutterCommand => platform.isWindows ? 'flutter.bat' : 'flutter'; /// The shard of the overall command execution that this instance should run. int get shardIndex { if (_shardIndex == null) { _checkSharding(); } return _shardIndex!; } /// The number of shards this command is divided into. int get shardCount { if (_shardCount == null) { _checkSharding(); } return _shardCount!; } /// Returns the [GitDir] containing [packagesDir]. Future get gitDir async { GitDir? gitDir = _gitDir; if (gitDir != null) { return gitDir; } // Ensure there are no symlinks in the path, as it can break // GitDir's allowSubdirectory:true. final String packagesPath = packagesDir.resolveSymbolicLinksSync(); if (!await GitDir.isGitDir(packagesPath)) { printError('$packagesPath is not a valid Git repository.'); throw ToolExit(2); } gitDir = await GitDir.fromExisting(packagesDir.path, allowSubdirectory: true); _gitDir = gitDir; return gitDir; } /// Convenience accessor for boolean arguments. bool getBoolArg(String key) { return (argResults![key] as bool?) ?? false; } /// Convenience accessor for String arguments. String getStringArg(String key) { return (argResults![key] as String?) ?? ''; } /// Convenience accessor for List arguments. List getStringListArg(String key) { return (argResults![key] as List?) ?? []; } /// If true, commands should log timing information that might be useful in /// analyzing their runtime (e.g., the per-package time for multi-package /// commands). bool get shouldLogTiming => getBoolArg(_logTimingArg); void _checkSharding() { final int? shardIndex = int.tryParse(getStringArg(_shardIndexArg)); final int? shardCount = int.tryParse(getStringArg(_shardCountArg)); if (shardIndex == null) { usageException('$_shardIndexArg must be an integer'); } if (shardCount == null) { usageException('$_shardCountArg must be an integer'); } if (shardCount < 1) { usageException('$_shardCountArg must be positive'); } if (shardIndex < 0 || shardCount <= shardIndex) { usageException( '$_shardIndexArg must be in the half-open range [0..$shardCount['); } _shardIndex = shardIndex; _shardCount = shardCount; } /// Returns the set of plugins to exclude based on the `--exclude` argument. Set getExcludedPackageNames() { final Set excludedPackages = _excludedPackages ?? getStringListArg(_excludeArg).expand((String item) { if (item.endsWith('.yaml')) { final File file = packagesDir.fileSystem.file(item); return (loadYaml(file.readAsStringSync()) as YamlList) .toList() .cast(); } return [item]; }).toSet(); // Cache for future calls. _excludedPackages = excludedPackages; return excludedPackages; } /// Returns the root diretories of the packages involved in this command /// execution. /// /// Depending on the command arguments, this may be a user-specified set of /// packages, the set of packages that should be run for a given diff, or all /// packages. /// /// By default, packages excluded via --exclude will not be in the stream, but /// they can be included by passing false for [filterExcluded]. Stream getTargetPackages( {bool filterExcluded = true}) async* { // To avoid assuming consistency of `Directory.list` across command // invocations, we collect and sort the plugin folders before sharding. // This is considered an implementation detail which is why the API still // uses streams. final List allPlugins = await _getAllPackages().toList(); allPlugins.sort((PackageEnumerationEntry p1, PackageEnumerationEntry p2) => p1.package.path.compareTo(p2.package.path)); final int shardSize = allPlugins.length ~/ shardCount + (allPlugins.length % shardCount == 0 ? 0 : 1); final int start = min(shardIndex * shardSize, allPlugins.length); final int end = min(start + shardSize, allPlugins.length); for (final PackageEnumerationEntry plugin in allPlugins.sublist(start, end)) { if (!(filterExcluded && plugin.excluded)) { yield plugin; } } } /// Returns the root Dart package folders of the packages involved in this /// command execution, assuming there is only one shard. Depending on the /// command arguments, this may be a user-specified set of packages, the /// set of packages that should be run for a given diff, or all packages. /// /// This will return packages that have been excluded by the --exclude /// parameter, annotated in the entry as excluded. /// /// Packages can exist in the following places relative to the packages /// directory: /// /// 1. As a Dart package in a directory which is a direct child of the /// packages directory. This is a non-plugin package, or a non-federated /// plugin. /// 2. Several plugin packages may live in a directory which is a direct /// child of the packages directory. This directory groups several Dart /// packages which implement a single plugin. This directory contains an /// "app-facing" package which declares the API for the plugin, a /// platform interface package which declares the API for implementations, /// and one or more platform-specific implementation packages. /// 3./4. Either of the above, but in a third_party/packages/ directory that /// is a sibling of the packages directory. This is used for a small number /// of packages in the flutter/packages repository. Stream _getAllPackages() async* { final Set packageSelectionFlags = { _packagesArg, _runOnChangedPackagesArg, _runOnDirtyPackagesArg, _packagesForBranchArg, }; if (packageSelectionFlags .where((String flag) => argResults!.wasParsed(flag)) .length > 1) { printError('Only one of --$_packagesArg, --$_runOnChangedPackagesArg, or ' '--$_packagesForBranchArg can be provided.'); throw ToolExit(exitInvalidArguments); } Set packages = Set.from(getStringListArg(_packagesArg)); final bool runOnChangedPackages; if (getBoolArg(_runOnChangedPackagesArg)) { runOnChangedPackages = true; } else if (getBoolArg(_packagesForBranchArg)) { final String? branch = await _getBranch(); if (branch == null) { printError('Unabled to determine branch; --$_packagesForBranchArg can ' 'only be used in a git repository.'); throw ToolExit(exitInvalidArguments); } else { runOnChangedPackages = branch != 'master' && branch != 'main'; // Log the mode for auditing what was intended to run. print('--$_packagesForBranchArg: running on ' '${runOnChangedPackages ? 'changed' : 'all'} packages'); } } else { runOnChangedPackages = false; } final Set excludedPluginNames = getExcludedPackageNames(); if (runOnChangedPackages) { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); final String baseSha = await gitVersionFinder.getBaseSha(); print( 'Running for all packages that have changed relative to "$baseSha"\n'); final List changedFiles = await gitVersionFinder.getChangedFiles(); if (!_changesRequireFullTest(changedFiles)) { packages = _getChangedPackageNames(changedFiles); } } else if (getBoolArg(_runOnDirtyPackagesArg)) { final GitVersionFinder gitVersionFinder = GitVersionFinder(await gitDir, 'HEAD'); print('Running for all packages that have uncommitted changes\n'); // _changesRequireFullTest is deliberately not used here, as this flag is // intended for use in CI to re-test packages changed by // 'make-deps-path-based'. packages = _getChangedPackageNames( await gitVersionFinder.getChangedFiles(includeUncommitted: true)); // For the same reason, empty is not treated as "all packages" as it is // for other flags. if (packages.isEmpty) { return; } } final Directory thirdPartyPackagesDirectory = packagesDir.parent .childDirectory('third_party') .childDirectory('packages'); for (final Directory dir in [ packagesDir, if (thirdPartyPackagesDirectory.existsSync()) thirdPartyPackagesDirectory, ]) { await for (final FileSystemEntity entity in dir.list(followLinks: false)) { // A top-level Dart package is a plugin package. if (isPackage(entity)) { if (packages.isEmpty || packages.contains(p.basename(entity.path))) { yield PackageEnumerationEntry( RepositoryPackage(entity as Directory), excluded: excludedPluginNames.contains(entity.basename)); } } else if (entity is Directory) { // Look for Dart packages under this top-level directory. await for (final FileSystemEntity subdir in entity.list(followLinks: false)) { if (isPackage(subdir)) { // There are three ways for a federated plugin to match: // - package name (path_provider_android) // - fully specified name (path_provider/path_provider_android) // - group name (path_provider), which matches all packages in // the group final Set possibleMatches = { path.basename(subdir.path), // package name path.basename(entity.path), // group name path.relative(subdir.path, from: dir.path), // fully specified }; if (packages.isEmpty || packages.intersection(possibleMatches).isNotEmpty) { yield PackageEnumerationEntry( RepositoryPackage(subdir as Directory), excluded: excludedPluginNames .intersection(possibleMatches) .isNotEmpty); } } } } } } } /// Returns all Dart package folders (typically, base package + example) of /// the packages involved in this command execution. /// /// By default, packages excluded via --exclude will not be in the stream, but /// they can be included by passing false for [filterExcluded]. /// /// Subpackages are guaranteed to be after the containing package in the /// stream. Stream getTargetPackagesAndSubpackages( {bool filterExcluded = true}) async* { await for (final PackageEnumerationEntry plugin in getTargetPackages(filterExcluded: filterExcluded)) { yield plugin; yield* getSubpackages(plugin.package).map((RepositoryPackage package) => PackageEnumerationEntry(package, excluded: plugin.excluded)); } } /// Returns all Dart package folders (e.g., examples) under the given package. Stream getSubpackages(RepositoryPackage package, {bool filterExcluded = true}) async* { yield* package.directory .list(recursive: true, followLinks: false) .where(isPackage) .map((FileSystemEntity directory) => // isPackage guarantees that this cast is valid. RepositoryPackage(directory as Directory)); } /// Returns the files contained, recursively, within the packages /// involved in this command execution. Stream getFiles() { return getTargetPackages().asyncExpand( (PackageEnumerationEntry entry) => getFilesForPackage(entry.package)); } /// Returns the files contained, recursively, within [package]. Stream getFilesForPackage(RepositoryPackage package) { return package.directory .list(recursive: true, followLinks: false) .where((FileSystemEntity entity) => entity is File) .cast(); } /// Retrieve an instance of [GitVersionFinder] based on `_baseShaArg` and [gitDir]. /// /// Throws tool exit if [gitDir] nor root directory is a git directory. Future retrieveVersionFinder() async { final String baseSha = getStringArg(_baseShaArg); final GitVersionFinder gitVersionFinder = GitVersionFinder(await gitDir, baseSha); return gitVersionFinder; } // Returns the names of packages that have been changed given a list of // changed files. // // The names will either be the actual package names, or potentially // group/name specifiers (for example, path_provider/path_provider) for // packages in federated plugins. // // The paths must use POSIX separators (e.g., as provided by git output). Set _getChangedPackageNames(List changedFiles) { final Set packages = {}; // A helper function that returns true if candidatePackageName looks like an // implementation package of a plugin called pluginName. Used to determine // if .../packages/parentName/candidatePackageName/... // looks like a path in a federated plugin package (candidatePackageName) // rather than a top-level package (parentName). bool isFederatedPackage(String candidatePackageName, String parentName) { return candidatePackageName == parentName || candidatePackageName.startsWith('${parentName}_'); } for (final String path in changedFiles) { final List pathComponents = p.posix.split(path); final int packagesIndex = pathComponents.indexWhere((String element) => element == 'packages'); if (packagesIndex != -1) { // Find the name of the directory directly under packages. This is // either the name of the package, or a plugin group directory for // a federated plugin. final String topLevelName = pathComponents[packagesIndex + 1]; String packageName = topLevelName; if (packagesIndex + 2 < pathComponents.length && isFederatedPackage( pathComponents[packagesIndex + 2], topLevelName)) { // This looks like a federated package; use the full specifier if // the name would be ambiguous (i.e., for the app-facing package). packageName = pathComponents[packagesIndex + 2]; if (packageName == topLevelName) { packageName = '$topLevelName/$packageName'; } } packages.add(packageName); } } if (packages.isEmpty) { print('No changed packages.'); } else { final String changedPackages = packages.join(','); print('Changed packages: $changedPackages'); } return packages; } Future _getBranch() async { final io.ProcessResult branchResult = await (await gitDir).runCommand( ['rev-parse', '--abbrev-ref', 'HEAD'], throwOnError: false); if (branchResult.exitCode != 0) { return null; } return (branchResult.stdout as String).trim(); } // Returns true if one or more files changed that have the potential to affect // any plugin (e.g., CI script changes). bool _changesRequireFullTest(List changedFiles) { const List specialFiles = [ '.ci.yaml', // LUCI config. '.cirrus.yml', // Cirrus config. '.clang-format', // ObjC and C/C++ formatting options. 'analysis_options.yaml', // Dart analysis settings. ]; const List specialDirectories = [ '.ci/', // Support files for CI. 'script/', // This tool, and its wrapper scripts. ]; // Directory entries must end with / to avoid over-matching, since the // check below is done via string prefixing. assert(specialDirectories.every((String dir) => dir.endsWith('/'))); return changedFiles.any((String path) => specialFiles.contains(path) || specialDirectories.any((String dir) => path.startsWith(dir))); } }