forked from flutter/plugins
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathplugin_command.dart
477 lines (433 loc) · 18.1 KB
/
plugin_command.dart
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
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
// 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<void> {
/// 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: <String>[_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: <String>[],
);
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'
'The packages excluded with $_excludeArg is also excluded even if changed.\n'
'See $_kBaseSha if a custom base is needed to determine the diff.\n\n'
'Cannot be combined with $_packagesArg.\n');
argParser.addFlag(_packagesForBranchArg,
help:
'This runs on all packages (equivalent to no package selection flag)\n'
'on 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(_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 _packagesArg = 'packages';
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 _packagesForBranchArg = 'packages-for-branch';
static const String _kBaseSha = 'base-sha';
/// 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<String>? _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<GitDir> 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<String> arguments.
List<String> getStringListArg(String key) {
return (argResults![key] as List<String>?) ?? <String>[];
}
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<String> getExcludedPackageNames() {
final Set<String> excludedPackages = _excludedPackages ??
getStringListArg(_excludeArg).expand<String>((String item) {
if (item.endsWith('.yaml')) {
final File file = packagesDir.fileSystem.file(item);
return (loadYaml(file.readAsStringSync()) as YamlList)
.toList()
.cast<String>();
}
return <String>[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<PackageEnumerationEntry> 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<PackageEnumerationEntry> 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<PackageEnumerationEntry> _getAllPackages() async* {
final Set<String> packageSelectionFlags = <String>{
_packagesArg,
_runOnChangedPackagesArg,
_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<String> plugins = Set<String>.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';
// Log the mode for auditing what was intended to run.
print('--$_packagesForBranchArg: running on '
'${runOnChangedPackages ? 'changed' : 'all'} packages');
}
} else {
runOnChangedPackages = false;
}
final Set<String> 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<String> changedFiles =
await gitVersionFinder.getChangedFiles();
if (!_changesRequireFullTest(changedFiles)) {
plugins = _getChangedPackages(changedFiles);
}
}
final Directory thirdPartyPackagesDirectory = packagesDir.parent
.childDirectory('third_party')
.childDirectory('packages');
for (final Directory dir in <Directory>[
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 (_isDartPackage(entity)) {
if (plugins.isEmpty || plugins.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 (_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 =
path.relative(subdir.path, from: dir.path);
final String packageName = path.basename(subdir.path);
final String basenamePath = path.basename(entity.path);
if (plugins.isEmpty ||
plugins.contains(relativePath) ||
plugins.contains(basenamePath)) {
yield PackageEnumerationEntry(
RepositoryPackage(subdir as Directory),
excluded: excludedPluginNames.contains(basenamePath) ||
excludedPluginNames.contains(packageName) ||
excludedPluginNames.contains(relativePath));
}
}
}
}
}
}
}
/// 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].
Stream<PackageEnumerationEntry> getTargetPackagesAndSubpackages(
{bool filterExcluded = true}) async* {
await for (final PackageEnumerationEntry plugin
in getTargetPackages(filterExcluded: filterExcluded)) {
yield plugin;
yield* plugin.package.directory
.list(recursive: true, followLinks: false)
.where(_isDartPackage)
.map((FileSystemEntity directory) => PackageEnumerationEntry(
// _isDartPackage guarantees that this cast is valid.
RepositoryPackage(directory as Directory),
excluded: plugin.excluded));
}
}
/// Returns the files contained, recursively, within the packages
/// involved in this command execution.
Stream<File> getFiles() {
return getTargetPackages().asyncExpand<File>(
(PackageEnumerationEntry entry) => getFilesForPackage(entry.package));
}
/// Returns the files contained, recursively, within [package].
Stream<File> getFilesForPackage(RepositoryPackage package) {
return package.directory
.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 && entity.childFile('pubspec.yaml').existsSync();
}
/// 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 baseSha = getStringArg(_kBaseSha);
final GitVersionFinder gitVersionFinder =
GitVersionFinder(await gitDir, baseSha);
return gitVersionFinder;
}
// Returns packages that have been changed given a list of changed files.
//
// The paths must use POSIX separators (e.g., as provided by git output).
Set<String> _getChangedPackages(List<String> changedFiles) {
final Set<String> packages = <String>{};
for (final String path in changedFiles) {
final List<String> pathComponents = p.posix.split(path);
final int packagesIndex =
pathComponents.indexWhere((String element) => element == 'packages');
if (packagesIndex != -1) {
packages.add(pathComponents[packagesIndex + 1]);
}
}
if (packages.isEmpty) {
print('No changed packages.');
} else {
final String changedPackages = packages.join(',');
print('Changed packages: $changedPackages');
}
return packages;
}
Future<String?> _getBranch() async {
final io.ProcessResult branchResult = await (await gitDir).runCommand(
<String>['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<String> changedFiles) {
const List<String> specialFiles = <String>[
'.ci.yaml', // LUCI config.
'.cirrus.yml', // Cirrus config.
'.clang-format', // ObjC and C/C++ formatting options.
'analysis_options.yaml', // Dart analysis settings.
];
const List<String> specialDirectories = <String>[
'.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)));
}
}