blob: d8826c7930882f8c1d7bbae155f713dbe23ea3d8 [file] [log] [blame]
// 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:async';
import 'dart:convert';
import 'dart:io' as io;
import 'dart:math';
import 'package:args/command_runner.dart';
import 'package:colorize/colorize.dart';
import 'package:file/file.dart';
import 'package:git/git.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
/// The signature for a print handler for commands that allow overriding the
/// print destination.
typedef Print = void Function(Object object);
/// Key for windows platform.
const String kWindows = 'windows';
/// Key for macos platform.
const String kMacos = 'macos';
/// Key for linux platform.
const String kLinux = 'linux';
/// Key for IPA (iOS) platform.
const String kIos = 'ios';
/// Key for APK (Android) platform.
const String kAndroid = 'android';
/// Key for Web platform.
const String kWeb = 'web';
/// Key for IPA.
const String kIpa = 'ipa';
/// Key for APK.
const String kApk = 'apk';
/// Key for enable experiment.
const String kEnableExperiment = 'enable-experiment';
/// Returns whether the given directory contains a Flutter package.
bool isFlutterPackage(FileSystemEntity entity, FileSystem fileSystem) {
if (entity == null || entity is! Directory) {
return false;
}
try {
final File pubspecFile =
fileSystem.file(p.join(entity.path, 'pubspec.yaml'));
final YamlMap pubspecYaml =
loadYaml(pubspecFile.readAsStringSync()) as YamlMap;
final YamlMap dependencies = pubspecYaml['dependencies'] as YamlMap;
if (dependencies == null) {
return false;
}
return dependencies.containsKey('flutter');
} on FileSystemException {
return false;
} on YamlException {
return false;
}
}
/// Returns whether the given directory contains a Flutter [platform] plugin.
///
/// It checks this by looking for the following pattern in the pubspec:
///
/// flutter:
/// plugin:
/// platforms:
/// [platform]:
bool pluginSupportsPlatform(
String platform, FileSystemEntity entity, FileSystem fileSystem) {
assert(platform == kIos ||
platform == kAndroid ||
platform == kWeb ||
platform == kMacos ||
platform == kWindows ||
platform == kLinux);
if (entity == null || entity is! Directory) {
return false;
}
try {
final File pubspecFile =
fileSystem.file(p.join(entity.path, 'pubspec.yaml'));
final YamlMap pubspecYaml =
loadYaml(pubspecFile.readAsStringSync()) as YamlMap;
final YamlMap flutterSection = pubspecYaml['flutter'] as YamlMap;
if (flutterSection == null) {
return false;
}
final YamlMap pluginSection = flutterSection['plugin'] as YamlMap;
if (pluginSection == null) {
return false;
}
final YamlMap platforms = pluginSection['platforms'] as YamlMap;
if (platforms == null) {
// Legacy plugin specs are assumed to support iOS and Android.
if (!pluginSection.containsKey('platforms')) {
return platform == kIos || platform == kAndroid;
}
return false;
}
return platforms.containsKey(platform);
} on FileSystemException {
return false;
} on YamlException {
return false;
}
}
/// Returns whether the given directory contains a Flutter Android plugin.
bool isAndroidPlugin(FileSystemEntity entity, FileSystem fileSystem) {
return pluginSupportsPlatform(kAndroid, entity, fileSystem);
}
/// Returns whether the given directory contains a Flutter iOS plugin.
bool isIosPlugin(FileSystemEntity entity, FileSystem fileSystem) {
return pluginSupportsPlatform(kIos, entity, fileSystem);
}
/// Returns whether the given directory contains a Flutter web plugin.
bool isWebPlugin(FileSystemEntity entity, FileSystem fileSystem) {
return pluginSupportsPlatform(kWeb, entity, fileSystem);
}
/// Returns whether the given directory contains a Flutter Windows plugin.
bool isWindowsPlugin(FileSystemEntity entity, FileSystem fileSystem) {
return pluginSupportsPlatform(kWindows, entity, fileSystem);
}
/// Returns whether the given directory contains a Flutter macOS plugin.
bool isMacOsPlugin(FileSystemEntity entity, FileSystem fileSystem) {
return pluginSupportsPlatform(kMacos, entity, fileSystem);
}
/// Returns whether the given directory contains a Flutter linux plugin.
bool isLinuxPlugin(FileSystemEntity entity, FileSystem fileSystem) {
return pluginSupportsPlatform(kLinux, entity, fileSystem);
}
/// Throws a [ToolExit] with `exitCode` and log the `errorMessage` in red.
void printErrorAndExit({@required String errorMessage, int exitCode = 1}) {
final Colorize redError = Colorize(errorMessage)..red();
print(redError);
throw ToolExit(exitCode);
}
/// Error thrown when a command needs to exit with a non-zero exit code.
class ToolExit extends Error {
/// Creates a tool exit with the given [exitCode].
ToolExit(this.exitCode);
/// The code that the process should exit with.
final int exitCode;
}
/// Interface definition for all commands in this tool.
abstract class PluginCommand extends Command<void> {
/// Creates a command to operate on [packagesDir] with the given environment.
PluginCommand(
this.packagesDir,
this.fileSystem, {
this.processRunner = const ProcessRunner(),
this.gitDir,
}) {
argParser.addMultiOption(
_pluginsArg,
splitCommas: true,
help:
'Specifies which plugins the command should run on (before sharding).',
valueHelp: 'plugin1,plugin2,...',
);
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: 'Exclude packages from this command.',
defaultsTo: <String>[],
);
argParser.addFlag(_runOnChangedPackagesArg,
help: 'Run the command on changed packages/plugins.\n'
'If the $_pluginsArg is specified, this flag is ignored.\n'
'The packages excluded with $_excludeArg is also excluded even if changed.\n'
'See $_kBaseSha if a custom base is needed to determine the diff.');
argParser.addOption(_kBaseSha,
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.');
}
static const String _pluginsArg = 'plugins';
static const String _shardIndexArg = 'shardIndex';
static const String _shardCountArg = 'shardCount';
static const String _excludeArg = 'exclude';
static const String _runOnChangedPackagesArg = 'run-on-changed-packages';
static const String _kBaseSha = 'base-sha';
/// The directory containing the plugin packages.
final Directory packagesDir;
/// The file system.
///
/// This can be overridden for testing.
final FileSystem fileSystem;
/// The process runner.
///
/// This can be overridden for testing.
final ProcessRunner processRunner;
/// The git directory to use. By default it uses the parent directory.
///
/// This can be mocked for testing.
final GitDir gitDir;
int _shardIndex;
int _shardCount;
/// 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;
}
void _checkSharding() {
final int shardIndex = int.tryParse(argResults[_shardIndexArg] as String);
final int shardCount = int.tryParse(argResults[_shardCountArg] as String);
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 root Dart package folders of the plugins involved in this
/// command execution.
Stream<Directory> getPlugins() 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<Directory> allPlugins = await _getAllPlugins().toList();
allPlugins.sort((Directory d1, Directory d2) => d1.path.compareTo(d2.path));
// Sharding 10 elements into 3 shards should yield shard sizes 4, 4, 2.
// Sharding 9 elements into 3 shards should yield shard sizes 3, 3, 3.
// Sharding 2 elements into 3 shards should yield shard sizes 1, 1, 0.
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 Directory plugin in allPlugins.sublist(start, end)) {
yield plugin;
}
}
/// Returns the root Dart package folders of the plugins involved in this
/// command execution, assuming there is only one shard.
///
/// Plugin packages can exist in one of two 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 plugin where all of the implementations
/// exist in a single Dart package.
/// 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 a
/// "client library" package, which declares the API for the plugin, as
/// well as one or more platform-specific implementations.
Stream<Directory> _getAllPlugins() async* {
Set<String> plugins =
Set<String>.from(argResults[_pluginsArg] as List<String>);
final Set<String> excludedPlugins =
Set<String>.from(argResults[_excludeArg] as List<String>);
final bool runOnChangedPackages =
argResults[_runOnChangedPackagesArg] as bool;
if (plugins.isEmpty && runOnChangedPackages) {
plugins = await _getChangedPackages();
}
await for (final FileSystemEntity entity
in packagesDir.list(followLinks: false)) {
// A top-level Dart package is a plugin package.
if (_isDartPackage(entity)) {
if (!excludedPlugins.contains(entity.basename) &&
(plugins.isEmpty || plugins.contains(p.basename(entity.path)))) {
yield entity as Directory;
}
} 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 (_isDartPackage(subdir)) {
// If --plugin=my_plugin is passed, then match all federated
// plugins under 'my_plugin'. Also match if the exact plugin is
// passed.
final String relativePath =
p.relative(subdir.path, from: packagesDir.path);
final String packageName = p.basename(subdir.path);
final String basenamePath = p.basename(entity.path);
if (!excludedPlugins.contains(basenamePath) &&
!excludedPlugins.contains(packageName) &&
!excludedPlugins.contains(relativePath) &&
(plugins.isEmpty ||
plugins.contains(relativePath) ||
plugins.contains(basenamePath))) {
yield subdir as Directory;
}
}
}
}
}
}
/// Returns the example Dart package folders of the plugins involved in this
/// command execution.
Stream<Directory> getExamples() =>
getPlugins().expand<Directory>(getExamplesForPlugin);
/// Returns all Dart package folders (typically, plugin + example) of the
/// plugins involved in this command execution.
Stream<Directory> getPackages() async* {
await for (final Directory plugin in getPlugins()) {
yield plugin;
yield* plugin
.list(recursive: true, followLinks: false)
.where(_isDartPackage)
.cast<Directory>();
}
}
/// Returns the files contained, recursively, within the plugins
/// involved in this command execution.
Stream<File> getFiles() {
return getPlugins().asyncExpand<File>((Directory folder) => folder
.list(recursive: true, followLinks: false)
.where((FileSystemEntity entity) => entity is File)
.cast<File>());
}
/// Returns whether the specified entity is a directory containing a
/// `pubspec.yaml` file.
bool _isDartPackage(FileSystemEntity entity) {
return entity is Directory &&
fileSystem.file(p.join(entity.path, 'pubspec.yaml')).existsSync();
}
/// Returns the example Dart packages contained in the specified plugin, or
/// an empty List, if the plugin has no examples.
Iterable<Directory> getExamplesForPlugin(Directory plugin) {
final Directory exampleFolder =
fileSystem.directory(p.join(plugin.path, 'example'));
if (!exampleFolder.existsSync()) {
return <Directory>[];
}
if (isFlutterPackage(exampleFolder, fileSystem)) {
return <Directory>[exampleFolder];
}
// Only look at the subdirectories of the example directory if the example
// directory itself is not a Dart package, and only look one level below the
// example directory for other dart packages.
return exampleFolder
.listSync()
.where(
(FileSystemEntity entity) => isFlutterPackage(entity, fileSystem))
.cast<Directory>();
}
/// Retrieve an instance of [GitVersionFinder] based on `_kBaseSha` and [gitDir].
///
/// Throws tool exit if [gitDir] nor root directory is a git directory.
Future<GitVersionFinder> retrieveVersionFinder() async {
final String rootDir = packagesDir.parent.absolute.path;
final String baseSha = argResults[_kBaseSha] as String;
GitDir baseGitDir = gitDir;
if (baseGitDir == null) {
if (!await GitDir.isGitDir(rootDir)) {
printErrorAndExit(
errorMessage: '$rootDir is not a valid Git repository.',
exitCode: 2);
}
baseGitDir = await GitDir.fromExisting(rootDir);
}
final GitVersionFinder gitVersionFinder =
GitVersionFinder(baseGitDir, baseSha);
return gitVersionFinder;
}
Future<Set<String>> _getChangedPackages() async {
final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
final List<String> allChangedFiles =
await gitVersionFinder.getChangedFiles();
final Set<String> packages = <String>{};
for (final String path in allChangedFiles) {
final List<String> pathComponents = path.split('/');
final int packagesIndex =
pathComponents.indexWhere((String element) => element == 'packages');
if (packagesIndex != -1) {
packages.add(pathComponents[packagesIndex + 1]);
}
}
if (packages.isNotEmpty) {
final String changedPackages = packages.join(',');
print(changedPackages);
}
print('No changed packages.');
return packages;
}
}
/// A class used to run processes.
///
/// We use this instead of directly running the process so it can be overridden
/// in tests.
class ProcessRunner {
/// Creates a new process runner.
const ProcessRunner();
/// Run the [executable] with [args] and stream output to stderr and stdout.
///
/// The current working directory of [executable] can be overridden by
/// passing [workingDir].
///
/// If [exitOnError] is set to `true`, then this will throw an error if
/// the [executable] terminates with a non-zero exit code.
///
/// Returns the exit code of the [executable].
Future<int> runAndStream(
String executable,
List<String> args, {
Directory workingDir,
bool exitOnError = false,
}) async {
print(
'Running command: "$executable ${args.join(' ')}" in ${workingDir?.path ?? io.Directory.current.path}');
final io.Process process = await io.Process.start(executable, args,
workingDirectory: workingDir?.path);
await io.stdout.addStream(process.stdout);
await io.stderr.addStream(process.stderr);
if (exitOnError && await process.exitCode != 0) {
final String error =
_getErrorString(executable, args, workingDir: workingDir);
print('$error See above for details.');
throw ToolExit(await process.exitCode);
}
return process.exitCode;
}
/// Run the [executable] with [args].
///
/// The current working directory of [executable] can be overridden by
/// passing [workingDir].
///
/// If [exitOnError] is set to `true`, then this will throw an error if
/// the [executable] terminates with a non-zero exit code.
///
/// Returns the [io.ProcessResult] of the [executable].
Future<io.ProcessResult> run(String executable, List<String> args,
{Directory workingDir,
bool exitOnError = false,
Encoding stdoutEncoding = io.systemEncoding,
Encoding stderrEncoding = io.systemEncoding}) async {
return io.Process.run(executable, args,
workingDirectory: workingDir?.path,
stdoutEncoding: stdoutEncoding,
stderrEncoding: stderrEncoding);
}
/// Starts the [executable] with [args].
///
/// The current working directory of [executable] can be overridden by
/// passing [workingDir].
///
/// Returns the started [io.Process].
Future<io.Process> start(String executable, List<String> args,
{Directory workingDirectory}) async {
final io.Process process = await io.Process.start(executable, args,
workingDirectory: workingDirectory?.path);
return process;
}
/// Run the [executable] with [args], throwing an error on non-zero exit code.
///
/// Unlike [runAndStream], this does not stream the process output to stdout.
/// It also unconditionally throws an error on a non-zero exit code.
///
/// The current working directory of [executable] can be overridden by
/// passing [workingDir].
///
/// Returns the [io.ProcessResult] of running the [executable].
Future<io.ProcessResult> runAndExitOnError(
String executable,
List<String> args, {
Directory workingDir,
}) async {
final io.ProcessResult result = await io.Process.run(executable, args,
workingDirectory: workingDir?.path);
if (result.exitCode != 0) {
final String error =
_getErrorString(executable, args, workingDir: workingDir);
print('$error Stderr:\n${result.stdout}');
throw ToolExit(result.exitCode);
}
return result;
}
String _getErrorString(String executable, List<String> args,
{Directory workingDir}) {
final String workdir = workingDir == null ? '' : ' in ${workingDir.path}';
return 'ERROR: Unable to execute "$executable ${args.join(' ')}"$workdir.';
}
}
/// Finding diffs based on `baseGitDir` and `baseSha`.
class GitVersionFinder {
/// Constructor
GitVersionFinder(this.baseGitDir, this.baseSha);
/// The top level directory of the git repo.
///
/// That is where the .git/ folder exists.
final GitDir baseGitDir;
/// The base sha used to get diff.
final String baseSha;
static bool _isPubspec(String file) {
return file.trim().endsWith('pubspec.yaml');
}
/// Get a list of all the pubspec.yaml file that is changed.
Future<List<String>> getChangedPubSpecs() async {
return (await getChangedFiles()).where(_isPubspec).toList();
}
/// Get a list of all the changed files.
Future<List<String>> getChangedFiles() async {
final String baseSha = await _getBaseSha();
final io.ProcessResult changedFilesCommand = await baseGitDir
.runCommand(<String>['diff', '--name-only', baseSha, 'HEAD']);
print('Determine diff with base sha: $baseSha');
final String changedFilesStdout =
changedFilesCommand.stdout.toString() ?? '';
if (changedFilesStdout.isEmpty) {
return <String>[];
}
final List<String> changedFiles = changedFilesStdout.split('\n')
..removeWhere((String element) => element.isEmpty);
return changedFiles.toList();
}
/// Get the package version specified in the pubspec file in `pubspecPath` and
/// at the revision of `gitRef` (defaulting to the base if not provided).
Future<Version> getPackageVersion(String pubspecPath, {String gitRef}) async {
final String ref = gitRef ?? (await _getBaseSha());
io.ProcessResult gitShow;
try {
gitShow =
await baseGitDir.runCommand(<String>['show', '$ref:$pubspecPath']);
} on io.ProcessException {
return null;
}
final String fileContent = gitShow.stdout as String;
final String versionString = loadYaml(fileContent)['version'] as String;
return versionString == null ? null : Version.parse(versionString);
}
Future<String> _getBaseSha() async {
if (baseSha != null && baseSha.isNotEmpty) {
return baseSha;
}
io.ProcessResult baseShaFromMergeBase = await baseGitDir.runCommand(
<String>['merge-base', '--fork-point', 'FETCH_HEAD', 'HEAD'],
throwOnError: false);
if (baseShaFromMergeBase == null ||
baseShaFromMergeBase.stderr != null ||
baseShaFromMergeBase.stdout == null) {
baseShaFromMergeBase = await baseGitDir
.runCommand(<String>['merge-base', 'FETCH_HEAD', 'HEAD']);
}
return (baseShaFromMergeBase.stdout as String).trim();
}
}