[flutter_plugin_tools] Split common.dart (#4057)

common.dart is a large-and-growing file containing all shared code,
which makes it hard to navigate. To make maintenance easier, this splits
the file (and its test file) into separate files for each major
component or category.
diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart
index 076c8f6..003f0bc 100644
--- a/script/tool/lib/src/analyze_command.dart
+++ b/script/tool/lib/src/analyze_command.dart
@@ -7,7 +7,9 @@
 import 'package:file/file.dart';
 import 'package:path/path.dart' as p;
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
 
 /// A command to run Dart analysis on packages.
 class AnalyzeCommand extends PluginCommand {
diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart
index 9590aec..61d291d 100644
--- a/script/tool/lib/src/build_examples_command.dart
+++ b/script/tool/lib/src/build_examples_command.dart
@@ -9,7 +9,10 @@
 import 'package:path/path.dart' as p;
 import 'package:platform/platform.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/plugin_utils.dart';
+import 'common/process_runner.dart';
 
 /// Key for IPA.
 const String kIpa = 'ipa';
diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart
deleted file mode 100644
index 5d653ad..0000000
--- a/script/tool/lib/src/common.dart
+++ /dev/null
@@ -1,781 +0,0 @@
-// 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:http/http.dart' as http;
-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 kPlatformFlagWindows = 'windows';
-
-/// Key for macos platform.
-const String kPlatformFlagMacos = 'macos';
-
-/// Key for linux platform.
-const String kPlatformFlagLinux = 'linux';
-
-/// Key for IPA (iOS) platform.
-const String kPlatformFlagIos = 'ios';
-
-/// Key for APK (Android) platform.
-const String kPlatformFlagAndroid = 'android';
-
-/// Key for Web platform.
-const String kPlatformFlagWeb = 'web';
-
-/// Key for enable experiment.
-const String kEnableExperiment = 'enable-experiment';
-
-/// Returns whether the given directory contains a Flutter package.
-bool isFlutterPackage(FileSystemEntity entity) {
-  if (entity is! Directory) {
-    return false;
-  }
-
-  try {
-    final File pubspecFile = entity.childFile('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;
-  }
-}
-
-/// Possible plugin support options for a platform.
-enum PlatformSupport {
-  /// The platform has an implementation in the package.
-  inline,
-
-  /// The platform has an endorsed federated implementation in another package.
-  federated,
-}
-
-/// 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]:
-///
-/// If [requiredMode] is provided, the plugin must have the given type of
-/// implementation in order to return true.
-bool pluginSupportsPlatform(String platform, FileSystemEntity entity,
-    {PlatformSupport? requiredMode}) {
-  assert(platform == kPlatformFlagIos ||
-      platform == kPlatformFlagAndroid ||
-      platform == kPlatformFlagWeb ||
-      platform == kPlatformFlagMacos ||
-      platform == kPlatformFlagWindows ||
-      platform == kPlatformFlagLinux);
-  if (entity is! Directory) {
-    return false;
-  }
-
-  try {
-    final File pubspecFile = entity.childFile('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. They are
-      // never federated.
-      if (requiredMode == PlatformSupport.federated) {
-        return false;
-      }
-      if (!pluginSection.containsKey('platforms')) {
-        return platform == kPlatformFlagIos || platform == kPlatformFlagAndroid;
-      }
-      return false;
-    }
-    final YamlMap? platformEntry = platforms[platform] as YamlMap?;
-    if (platformEntry == null) {
-      return false;
-    }
-    // If the platform entry is present, then it supports the platform. Check
-    // for required mode if specified.
-    final bool federated = platformEntry.containsKey('default_package');
-    return requiredMode == null ||
-        federated == (requiredMode == PlatformSupport.federated);
-  } on FileSystemException {
-    return false;
-  } on YamlException {
-    return false;
-  }
-}
-
-/// Returns whether the given directory contains a Flutter Android plugin.
-bool isAndroidPlugin(FileSystemEntity entity) {
-  return pluginSupportsPlatform(kPlatformFlagAndroid, entity);
-}
-
-/// Returns whether the given directory contains a Flutter iOS plugin.
-bool isIosPlugin(FileSystemEntity entity) {
-  return pluginSupportsPlatform(kPlatformFlagIos, entity);
-}
-
-/// Returns whether the given directory contains a Flutter web plugin.
-bool isWebPlugin(FileSystemEntity entity) {
-  return pluginSupportsPlatform(kPlatformFlagWeb, entity);
-}
-
-/// Returns whether the given directory contains a Flutter Windows plugin.
-bool isWindowsPlugin(FileSystemEntity entity) {
-  return pluginSupportsPlatform(kPlatformFlagWindows, entity);
-}
-
-/// Returns whether the given directory contains a Flutter macOS plugin.
-bool isMacOsPlugin(FileSystemEntity entity) {
-  return pluginSupportsPlatform(kPlatformFlagMacos, entity);
-}
-
-/// Returns whether the given directory contains a Flutter linux plugin.
-bool isLinuxPlugin(FileSystemEntity entity) {
-  return pluginSupportsPlatform(kPlatformFlagLinux, entity);
-}
-
-/// Prints `errorMessage` in red.
-void printError(String errorMessage) {
-  final Colorize redError = Colorize(errorMessage)..red();
-  print(redError);
-}
-
-/// 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.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'
-            '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.');
-    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 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!;
-  }
-
-  /// 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 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 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 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.
-  /// 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<Directory> _getAllPlugins() async* {
-    Set<String> plugins = Set<String>.from(getStringListArg(_pluginsArg));
-    final Set<String> excludedPlugins =
-        Set<String>.from(getStringListArg(_excludeArg));
-    final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg);
-    if (plugins.isEmpty &&
-        runOnChangedPackages &&
-        !(await _changesRequireFullTest())) {
-      plugins = await _getChangedPackages();
-    }
-
-    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 (!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: dir.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 && entity.childFile('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 = plugin.childDirectory('example');
-    if (!exampleFolder.existsSync()) {
-      return <Directory>[];
-    }
-    if (isFlutterPackage(exampleFolder)) {
-      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))
-        .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 = getStringArg(_kBaseSha);
-
-    GitDir? baseGitDir = gitDir;
-    if (baseGitDir == null) {
-      if (!await GitDir.isGitDir(rootDir)) {
-        printError(
-          '$rootDir is not a valid Git repository.',
-        );
-        throw ToolExit(2);
-      }
-      baseGitDir = await GitDir.fromExisting(rootDir);
-    }
-
-    final GitVersionFinder gitVersionFinder =
-        GitVersionFinder(baseGitDir, baseSha);
-    return gitVersionFinder;
-  }
-
-  // Returns packages that have been changed relative to the git base.
-  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.isEmpty) {
-      print('No changed packages.');
-    } else {
-      final String changedPackages = packages.join(',');
-      print('Changed packages: $changedPackages');
-    }
-    return packages;
-  }
-
-  // Returns true if one or more files changed that have the potential to affect
-  // any plugin (e.g., CI script changes).
-  Future<bool> _changesRequireFullTest() async {
-    final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
-
-    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('/')));
-
-    final List<String> allChangedFiles =
-        await gitVersionFinder.getChangedFiles();
-    return allChangedFiles.any((String path) =>
-        specialFiles.contains(path) ||
-        specialDirectories.any((String dir) => path.startsWith(dir)));
-  }
-}
-
-/// 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.
-  /// Defaults to `false`.
-  ///
-  /// If [logOnError] is set to `true`, it will print a formatted message about the error.
-  /// Defaults to `false`
-  ///
-  /// Returns the [io.ProcessResult] of the [executable].
-  Future<io.ProcessResult> run(String executable, List<String> args,
-      {Directory? workingDir,
-      bool exitOnError = false,
-      bool logOnError = false,
-      Encoding stdoutEncoding = io.systemEncoding,
-      Encoding stderrEncoding = io.systemEncoding}) async {
-    final io.ProcessResult result = await io.Process.run(executable, args,
-        workingDirectory: workingDir?.path,
-        stdoutEncoding: stdoutEncoding,
-        stderrEncoding: stderrEncoding);
-    if (result.exitCode != 0) {
-      if (logOnError) {
-        final String error =
-            _getErrorString(executable, args, workingDir: workingDir);
-        print('$error Stderr:\n${result.stdout}');
-      }
-      if (exitOnError) {
-        throw ToolExit(result.exitCode);
-      }
-    }
-    return result;
-  }
-
-  /// 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;
-  }
-
-  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 version of [package] that is published on pub.
-class PubVersionFinder {
-  /// Constructor.
-  ///
-  /// Note: you should manually close the [httpClient] when done using the finder.
-  PubVersionFinder({this.pubHost = defaultPubHost, required this.httpClient});
-
-  /// The default pub host to use.
-  static const String defaultPubHost = 'https://pub.dev';
-
-  /// The pub host url, defaults to `https://pub.dev`.
-  final String pubHost;
-
-  /// The http client.
-  ///
-  /// You should manually close this client when done using this finder.
-  final http.Client httpClient;
-
-  /// Get the package version on pub.
-  Future<PubVersionFinderResponse> getPackageVersion(
-      {required String package}) async {
-    assert(package.isNotEmpty);
-    final Uri pubHostUri = Uri.parse(pubHost);
-    final Uri url = pubHostUri.replace(path: '/packages/$package.json');
-    final http.Response response = await httpClient.get(url);
-
-    if (response.statusCode == 404) {
-      return PubVersionFinderResponse(
-          versions: <Version>[],
-          result: PubVersionFinderResult.noPackageFound,
-          httpResponse: response);
-    } else if (response.statusCode != 200) {
-      return PubVersionFinderResponse(
-          versions: <Version>[],
-          result: PubVersionFinderResult.fail,
-          httpResponse: response);
-    }
-    final List<Version> versions =
-        (json.decode(response.body)['versions'] as List<dynamic>)
-            .map<Version>((final dynamic versionString) =>
-                Version.parse(versionString as String))
-            .toList();
-
-    return PubVersionFinderResponse(
-        versions: versions,
-        result: PubVersionFinderResult.success,
-        httpResponse: response);
-  }
-}
-
-/// Represents a response for [PubVersionFinder].
-class PubVersionFinderResponse {
-  /// Constructor.
-  PubVersionFinderResponse(
-      {required this.versions,
-      required this.result,
-      required this.httpResponse}) {
-    if (versions.isNotEmpty) {
-      versions.sort((Version a, Version b) {
-        // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize].
-        // https://github.com/flutter/flutter/issues/82222
-        return b.compareTo(a);
-      });
-    }
-  }
-
-  /// The versions found in [PubVersionFinder].
-  ///
-  /// This is sorted by largest to smallest, so the first element in the list is the largest version.
-  /// Might be `null` if the [result] is not [PubVersionFinderResult.success].
-  final List<Version> versions;
-
-  /// The result of the version finder.
-  final PubVersionFinderResult result;
-
-  /// The response object of the http request.
-  final http.Response httpResponse;
-}
-
-/// An enum representing the result of [PubVersionFinder].
-enum PubVersionFinderResult {
-  /// The version finder successfully found a version.
-  success,
-
-  /// The version finder failed to find a valid version.
-  ///
-  /// This might due to http connection errors or user errors.
-  fail,
-
-  /// The version finder failed to locate the package.
-  ///
-  /// This indicates the package is new.
-  noPackageFound,
-}
-
-/// 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.stderr != null ||
-        baseShaFromMergeBase.stdout == null) {
-      baseShaFromMergeBase = await baseGitDir
-          .runCommand(<String>['merge-base', 'FETCH_HEAD', 'HEAD']);
-    }
-    return (baseShaFromMergeBase.stdout as String).trim();
-  }
-}
diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart
new file mode 100644
index 0000000..4788b9f
--- /dev/null
+++ b/script/tool/lib/src/common/core.dart
@@ -0,0 +1,69 @@
+// 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 'package:colorize/colorize.dart';
+import 'package:file/file.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 kPlatformFlagWindows = 'windows';
+
+/// Key for macos platform.
+const String kPlatformFlagMacos = 'macos';
+
+/// Key for linux platform.
+const String kPlatformFlagLinux = 'linux';
+
+/// Key for IPA (iOS) platform.
+const String kPlatformFlagIos = 'ios';
+
+/// Key for APK (Android) platform.
+const String kPlatformFlagAndroid = 'android';
+
+/// Key for Web platform.
+const String kPlatformFlagWeb = 'web';
+
+/// Key for enable experiment.
+const String kEnableExperiment = 'enable-experiment';
+
+/// Returns whether the given directory contains a Flutter package.
+bool isFlutterPackage(FileSystemEntity entity) {
+  if (entity is! Directory) {
+    return false;
+  }
+
+  try {
+    final File pubspecFile = entity.childFile('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;
+  }
+}
+
+/// Prints `errorMessage` in red.
+void printError(String errorMessage) {
+  final Colorize redError = Colorize(errorMessage)..red();
+  print(redError);
+}
+
+/// 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;
+}
diff --git a/script/tool/lib/src/common/git_version_finder.dart b/script/tool/lib/src/common/git_version_finder.dart
new file mode 100644
index 0000000..2c9519e
--- /dev/null
+++ b/script/tool/lib/src/common/git_version_finder.dart
@@ -0,0 +1,81 @@
+// 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 'package:git/git.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:yaml/yaml.dart';
+
+/// 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.stderr != null ||
+        baseShaFromMergeBase.stdout == null) {
+      baseShaFromMergeBase = await baseGitDir
+          .runCommand(<String>['merge-base', 'FETCH_HEAD', 'HEAD']);
+    }
+    return (baseShaFromMergeBase.stdout as String).trim();
+  }
+}
diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart
new file mode 100644
index 0000000..1ab9d8d
--- /dev/null
+++ b/script/tool/lib/src/common/plugin_command.dart
@@ -0,0 +1,353 @@
+// 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: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 'core.dart';
+import 'git_version_finder.dart';
+import 'process_runner.dart';
+
+/// 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.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'
+            '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.');
+    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 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!;
+  }
+
+  /// 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 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 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 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.
+  /// 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<Directory> _getAllPlugins() async* {
+    Set<String> plugins = Set<String>.from(getStringListArg(_pluginsArg));
+    final Set<String> excludedPlugins =
+        Set<String>.from(getStringListArg(_excludeArg));
+    final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg);
+    if (plugins.isEmpty &&
+        runOnChangedPackages &&
+        !(await _changesRequireFullTest())) {
+      plugins = await _getChangedPackages();
+    }
+
+    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 (!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: dir.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 && entity.childFile('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 = plugin.childDirectory('example');
+    if (!exampleFolder.existsSync()) {
+      return <Directory>[];
+    }
+    if (isFlutterPackage(exampleFolder)) {
+      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))
+        .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 = getStringArg(_kBaseSha);
+
+    GitDir? baseGitDir = gitDir;
+    if (baseGitDir == null) {
+      if (!await GitDir.isGitDir(rootDir)) {
+        printError(
+          '$rootDir is not a valid Git repository.',
+        );
+        throw ToolExit(2);
+      }
+      baseGitDir = await GitDir.fromExisting(rootDir);
+    }
+
+    final GitVersionFinder gitVersionFinder =
+        GitVersionFinder(baseGitDir, baseSha);
+    return gitVersionFinder;
+  }
+
+  // Returns packages that have been changed relative to the git base.
+  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.isEmpty) {
+      print('No changed packages.');
+    } else {
+      final String changedPackages = packages.join(',');
+      print('Changed packages: $changedPackages');
+    }
+    return packages;
+  }
+
+  // Returns true if one or more files changed that have the potential to affect
+  // any plugin (e.g., CI script changes).
+  Future<bool> _changesRequireFullTest() async {
+    final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
+
+    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('/')));
+
+    final List<String> allChangedFiles =
+        await gitVersionFinder.getChangedFiles();
+    return allChangedFiles.any((String path) =>
+        specialFiles.contains(path) ||
+        specialDirectories.any((String dir) => path.startsWith(dir)));
+  }
+}
diff --git a/script/tool/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart
new file mode 100644
index 0000000..b6ac433
--- /dev/null
+++ b/script/tool/lib/src/common/plugin_utils.dart
@@ -0,0 +1,110 @@
+// 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 'package:file/file.dart';
+import 'package:yaml/yaml.dart';
+
+import 'core.dart';
+
+/// Possible plugin support options for a platform.
+enum PlatformSupport {
+  /// The platform has an implementation in the package.
+  inline,
+
+  /// The platform has an endorsed federated implementation in another package.
+  federated,
+}
+
+/// 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]:
+///
+/// If [requiredMode] is provided, the plugin must have the given type of
+/// implementation in order to return true.
+bool pluginSupportsPlatform(String platform, FileSystemEntity entity,
+    {PlatformSupport? requiredMode}) {
+  assert(platform == kPlatformFlagIos ||
+      platform == kPlatformFlagAndroid ||
+      platform == kPlatformFlagWeb ||
+      platform == kPlatformFlagMacos ||
+      platform == kPlatformFlagWindows ||
+      platform == kPlatformFlagLinux);
+  if (entity is! Directory) {
+    return false;
+  }
+
+  try {
+    final File pubspecFile = entity.childFile('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. They are
+      // never federated.
+      if (requiredMode == PlatformSupport.federated) {
+        return false;
+      }
+      if (!pluginSection.containsKey('platforms')) {
+        return platform == kPlatformFlagIos || platform == kPlatformFlagAndroid;
+      }
+      return false;
+    }
+    final YamlMap? platformEntry = platforms[platform] as YamlMap?;
+    if (platformEntry == null) {
+      return false;
+    }
+    // If the platform entry is present, then it supports the platform. Check
+    // for required mode if specified.
+    final bool federated = platformEntry.containsKey('default_package');
+    return requiredMode == null ||
+        federated == (requiredMode == PlatformSupport.federated);
+  } on FileSystemException {
+    return false;
+  } on YamlException {
+    return false;
+  }
+}
+
+/// Returns whether the given directory contains a Flutter Android plugin.
+bool isAndroidPlugin(FileSystemEntity entity) {
+  return pluginSupportsPlatform(kPlatformFlagAndroid, entity);
+}
+
+/// Returns whether the given directory contains a Flutter iOS plugin.
+bool isIosPlugin(FileSystemEntity entity) {
+  return pluginSupportsPlatform(kPlatformFlagIos, entity);
+}
+
+/// Returns whether the given directory contains a Flutter web plugin.
+bool isWebPlugin(FileSystemEntity entity) {
+  return pluginSupportsPlatform(kPlatformFlagWeb, entity);
+}
+
+/// Returns whether the given directory contains a Flutter Windows plugin.
+bool isWindowsPlugin(FileSystemEntity entity) {
+  return pluginSupportsPlatform(kPlatformFlagWindows, entity);
+}
+
+/// Returns whether the given directory contains a Flutter macOS plugin.
+bool isMacOsPlugin(FileSystemEntity entity) {
+  return pluginSupportsPlatform(kPlatformFlagMacos, entity);
+}
+
+/// Returns whether the given directory contains a Flutter linux plugin.
+bool isLinuxPlugin(FileSystemEntity entity) {
+  return pluginSupportsPlatform(kPlatformFlagLinux, entity);
+}
diff --git a/script/tool/lib/src/common/process_runner.dart b/script/tool/lib/src/common/process_runner.dart
new file mode 100644
index 0000000..429761e
--- /dev/null
+++ b/script/tool/lib/src/common/process_runner.dart
@@ -0,0 +1,104 @@
+// 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:convert';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+
+import 'core.dart';
+
+/// 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.
+  /// Defaults to `false`.
+  ///
+  /// If [logOnError] is set to `true`, it will print a formatted message about the error.
+  /// Defaults to `false`
+  ///
+  /// Returns the [io.ProcessResult] of the [executable].
+  Future<io.ProcessResult> run(String executable, List<String> args,
+      {Directory? workingDir,
+      bool exitOnError = false,
+      bool logOnError = false,
+      Encoding stdoutEncoding = io.systemEncoding,
+      Encoding stderrEncoding = io.systemEncoding}) async {
+    final io.ProcessResult result = await io.Process.run(executable, args,
+        workingDirectory: workingDir?.path,
+        stdoutEncoding: stdoutEncoding,
+        stderrEncoding: stderrEncoding);
+    if (result.exitCode != 0) {
+      if (logOnError) {
+        final String error =
+            _getErrorString(executable, args, workingDir: workingDir);
+        print('$error Stderr:\n${result.stdout}');
+      }
+      if (exitOnError) {
+        throw ToolExit(result.exitCode);
+      }
+    }
+    return result;
+  }
+
+  /// 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;
+  }
+
+  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.';
+  }
+}
diff --git a/script/tool/lib/src/common/pub_version_finder.dart b/script/tool/lib/src/common/pub_version_finder.dart
new file mode 100644
index 0000000..ebac473
--- /dev/null
+++ b/script/tool/lib/src/common/pub_version_finder.dart
@@ -0,0 +1,103 @@
+// 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:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:pub_semver/pub_semver.dart';
+
+/// Finding version of [package] that is published on pub.
+class PubVersionFinder {
+  /// Constructor.
+  ///
+  /// Note: you should manually close the [httpClient] when done using the finder.
+  PubVersionFinder({this.pubHost = defaultPubHost, required this.httpClient});
+
+  /// The default pub host to use.
+  static const String defaultPubHost = 'https://pub.dev';
+
+  /// The pub host url, defaults to `https://pub.dev`.
+  final String pubHost;
+
+  /// The http client.
+  ///
+  /// You should manually close this client when done using this finder.
+  final http.Client httpClient;
+
+  /// Get the package version on pub.
+  Future<PubVersionFinderResponse> getPackageVersion(
+      {required String package}) async {
+    assert(package.isNotEmpty);
+    final Uri pubHostUri = Uri.parse(pubHost);
+    final Uri url = pubHostUri.replace(path: '/packages/$package.json');
+    final http.Response response = await httpClient.get(url);
+
+    if (response.statusCode == 404) {
+      return PubVersionFinderResponse(
+          versions: <Version>[],
+          result: PubVersionFinderResult.noPackageFound,
+          httpResponse: response);
+    } else if (response.statusCode != 200) {
+      return PubVersionFinderResponse(
+          versions: <Version>[],
+          result: PubVersionFinderResult.fail,
+          httpResponse: response);
+    }
+    final List<Version> versions =
+        (json.decode(response.body)['versions'] as List<dynamic>)
+            .map<Version>((final dynamic versionString) =>
+                Version.parse(versionString as String))
+            .toList();
+
+    return PubVersionFinderResponse(
+        versions: versions,
+        result: PubVersionFinderResult.success,
+        httpResponse: response);
+  }
+}
+
+/// Represents a response for [PubVersionFinder].
+class PubVersionFinderResponse {
+  /// Constructor.
+  PubVersionFinderResponse(
+      {required this.versions,
+      required this.result,
+      required this.httpResponse}) {
+    if (versions.isNotEmpty) {
+      versions.sort((Version a, Version b) {
+        // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize].
+        // https://github.com/flutter/flutter/issues/82222
+        return b.compareTo(a);
+      });
+    }
+  }
+
+  /// The versions found in [PubVersionFinder].
+  ///
+  /// This is sorted by largest to smallest, so the first element in the list is the largest version.
+  /// Might be `null` if the [result] is not [PubVersionFinderResult.success].
+  final List<Version> versions;
+
+  /// The result of the version finder.
+  final PubVersionFinderResult result;
+
+  /// The response object of the http request.
+  final http.Response httpResponse;
+}
+
+/// An enum representing the result of [PubVersionFinder].
+enum PubVersionFinderResult {
+  /// The version finder successfully found a version.
+  success,
+
+  /// The version finder failed to find a valid version.
+  ///
+  /// This might due to http connection errors or user errors.
+  fail,
+
+  /// The version finder failed to locate the package.
+  ///
+  /// This indicates the package is new.
+  noPackageFound,
+}
diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart
index cd5b85e..fab41bc 100644
--- a/script/tool/lib/src/create_all_plugins_app_command.dart
+++ b/script/tool/lib/src/create_all_plugins_app_command.dart
@@ -8,7 +8,8 @@
 import 'package:pub_semver/pub_semver.dart';
 import 'package:pubspec_parse/pubspec_parse.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
 
 /// A command to create an application that builds all in a single application.
 class CreateAllPluginsAppCommand extends PluginCommand {
diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart
index 14dfede..b6576cd 100644
--- a/script/tool/lib/src/drive_examples_command.dart
+++ b/script/tool/lib/src/drive_examples_command.dart
@@ -2,11 +2,14 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
 import 'package:file/file.dart';
 import 'package:path/path.dart' as p;
 import 'package:platform/platform.dart';
-import 'common.dart';
+
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/plugin_utils.dart';
+import 'common/process_runner.dart';
 
 /// A command to run the example applications for packages via Flutter driver.
 class DriveExamplesCommand extends PluginCommand {
diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart
index 741d856..b4f5e92 100644
--- a/script/tool/lib/src/firebase_test_lab_command.dart
+++ b/script/tool/lib/src/firebase_test_lab_command.dart
@@ -9,7 +9,9 @@
 import 'package:path/path.dart' as p;
 import 'package:uuid/uuid.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
 
 /// A command to run tests via Firebase test lab.
 class FirebaseTestLabCommand extends PluginCommand {
diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart
index 1ef41f8..5f060d7 100644
--- a/script/tool/lib/src/format_command.dart
+++ b/script/tool/lib/src/format_command.dart
@@ -2,7 +2,6 @@
 // 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;
 
@@ -11,7 +10,9 @@
 import 'package:path/path.dart' as p;
 import 'package:quiver/iterables.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
 
 final Uri _googleFormatterUrl = Uri.https('github.com',
     '/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar');
diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart
index d1366ea..d7e453b 100644
--- a/script/tool/lib/src/java_test_command.dart
+++ b/script/tool/lib/src/java_test_command.dart
@@ -2,12 +2,12 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
-
 import 'package:file/file.dart';
 import 'package:path/path.dart' as p;
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
 
 /// A command to run the Java tests of Android plugins.
 class JavaTestCommand extends PluginCommand {
diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart
index 805c3ab..4ea8a1e 100644
--- a/script/tool/lib/src/license_check_command.dart
+++ b/script/tool/lib/src/license_check_command.dart
@@ -2,12 +2,11 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
-
 import 'package:file/file.dart';
 import 'package:path/path.dart' as p;
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
 
 const Set<String> _codeFileExtensions = <String>{
   '.c',
diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart
index 364653b..5e86d2b 100644
--- a/script/tool/lib/src/lint_podspecs_command.dart
+++ b/script/tool/lib/src/lint_podspecs_command.dart
@@ -9,7 +9,9 @@
 import 'package:path/path.dart' as p;
 import 'package:platform/platform.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
 
 /// Lint the CocoaPod podspecs and run unit tests.
 ///
diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart
index f6b186e..39515cf 100644
--- a/script/tool/lib/src/list_command.dart
+++ b/script/tool/lib/src/list_command.dart
@@ -2,11 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
-
 import 'package:file/file.dart';
 
-import 'common.dart';
+import 'common/plugin_command.dart';
 
 /// A command to list different types of repository content.
 class ListCommand extends PluginCommand {
diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart
index a760312..f397a04 100644
--- a/script/tool/lib/src/main.dart
+++ b/script/tool/lib/src/main.dart
@@ -10,7 +10,7 @@
 
 import 'analyze_command.dart';
 import 'build_examples_command.dart';
-import 'common.dart';
+import 'common/core.dart';
 import 'create_all_plugins_app_command.dart';
 import 'drive_examples_command.dart';
 import 'firebase_test_lab_command.dart';
diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart
index b77ecee..82a7660 100644
--- a/script/tool/lib/src/publish_check_command.dart
+++ b/script/tool/lib/src/publish_check_command.dart
@@ -12,7 +12,10 @@
 import 'package:pub_semver/pub_semver.dart';
 import 'package:pubspec_parse/pubspec_parse.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
+import 'common/pub_version_finder.dart';
 
 /// A command to check that packages are publishable via 'dart publish'.
 class PublishCheckCommand extends PluginCommand {
diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart
index 1e7c150..70ec75b 100644
--- a/script/tool/lib/src/publish_plugin_command.dart
+++ b/script/tool/lib/src/publish_plugin_command.dart
@@ -14,7 +14,10 @@
 import 'package:pubspec_parse/pubspec_parse.dart';
 import 'package:yaml/yaml.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/git_version_finder.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
 
 @immutable
 class _RemoteInfo {
diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart
index 878b683..480d3a4 100644
--- a/script/tool/lib/src/pubspec_check_command.dart
+++ b/script/tool/lib/src/pubspec_check_command.dart
@@ -2,14 +2,14 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
-
 import 'package:file/file.dart';
 import 'package:git/git.dart';
 import 'package:path/path.dart' as p;
 import 'package:pubspec_parse/pubspec_parse.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
 
 /// A command to enforce pubspec conventions across the repository.
 ///
diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart
index 0174b98..b7bf261 100644
--- a/script/tool/lib/src/test_command.dart
+++ b/script/tool/lib/src/test_command.dart
@@ -2,12 +2,13 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
-
 import 'package:file/file.dart';
 import 'package:path/path.dart' as p;
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/plugin_utils.dart';
+import 'common/process_runner.dart';
 
 /// A command to run Dart unit tests for packages.
 class TestCommand extends PluginCommand {
diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart
index 6baa38e..5e9f553 100644
--- a/script/tool/lib/src/version_check_command.dart
+++ b/script/tool/lib/src/version_check_command.dart
@@ -2,8 +2,6 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
-
 import 'package:file/file.dart';
 import 'package:git/git.dart';
 import 'package:http/http.dart' as http;
@@ -11,7 +9,11 @@
 import 'package:pub_semver/pub_semver.dart';
 import 'package:pubspec_parse/pubspec_parse.dart';
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/git_version_finder.dart';
+import 'common/plugin_command.dart';
+import 'common/process_runner.dart';
+import 'common/pub_version_finder.dart';
 
 /// Categories of version change types.
 enum NextVersionType {
diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart
index 288851c..77e5659 100644
--- a/script/tool/lib/src/xctest_command.dart
+++ b/script/tool/lib/src/xctest_command.dart
@@ -2,14 +2,16 @@
 // 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 'package:file/file.dart';
 import 'package:path/path.dart' as p;
 
-import 'common.dart';
+import 'common/core.dart';
+import 'common/plugin_command.dart';
+import 'common/plugin_utils.dart';
+import 'common/process_runner.dart';
 
 const String _kiOSDestination = 'ios-destination';
 const String _kXcodeBuildCommand = 'xcodebuild';
diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart
index ec627f2..1ef4fdc 100644
--- a/script/tool/test/analyze_command_test.dart
+++ b/script/tool/test/analyze_command_test.dart
@@ -6,7 +6,7 @@
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
 import 'package:flutter_plugin_tools/src/analyze_command.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:test/test.dart';
 
 import 'mocks.dart';
diff --git a/script/tool/test/common/git_version_finder_test.dart b/script/tool/test/common/git_version_finder_test.dart
new file mode 100644
index 0000000..f1f40b5
--- /dev/null
+++ b/script/tool/test/common/git_version_finder_test.dart
@@ -0,0 +1,93 @@
+// 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';
+
+import 'package:flutter_plugin_tools/src/common/git_version_finder.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import 'plugin_command_test.mocks.dart';
+
+void main() {
+  late List<List<String>?> gitDirCommands;
+  late String gitDiffResponse;
+  late MockGitDir gitDir;
+  String? mergeBaseResponse;
+
+  setUp(() {
+    gitDirCommands = <List<String>?>[];
+    gitDiffResponse = '';
+    gitDir = MockGitDir();
+    when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError')))
+        .thenAnswer((Invocation invocation) {
+      gitDirCommands.add(invocation.positionalArguments[0] as List<String>?);
+      final MockProcessResult mockProcessResult = MockProcessResult();
+      if (invocation.positionalArguments[0][0] == 'diff') {
+        when<String?>(mockProcessResult.stdout as String?)
+            .thenReturn(gitDiffResponse);
+      } else if (invocation.positionalArguments[0][0] == 'merge-base') {
+        when<String?>(mockProcessResult.stdout as String?)
+            .thenReturn(mergeBaseResponse);
+      }
+      return Future<ProcessResult>.value(mockProcessResult);
+    });
+  });
+
+  test('No git diff should result no files changed', () async {
+    final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha');
+    final List<String> changedFiles = await finder.getChangedFiles();
+
+    expect(changedFiles, isEmpty);
+  });
+
+  test('get correct files changed based on git diff', () async {
+    gitDiffResponse = '''
+file1/file1.cc
+file2/file2.cc
+''';
+    final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha');
+    final List<String> changedFiles = await finder.getChangedFiles();
+
+    expect(changedFiles, equals(<String>['file1/file1.cc', 'file2/file2.cc']));
+  });
+
+  test('get correct pubspec change based on git diff', () async {
+    gitDiffResponse = '''
+file1/pubspec.yaml
+file2/file2.cc
+''';
+    final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha');
+    final List<String> changedFiles = await finder.getChangedPubSpecs();
+
+    expect(changedFiles, equals(<String>['file1/pubspec.yaml']));
+  });
+
+  test('use correct base sha if not specified', () async {
+    mergeBaseResponse = 'shaqwiueroaaidf12312jnadf123nd';
+    gitDiffResponse = '''
+file1/pubspec.yaml
+file2/file2.cc
+''';
+
+    final GitVersionFinder finder = GitVersionFinder(gitDir, null);
+    await finder.getChangedFiles();
+    verify(gitDir.runCommand(
+        <String>['diff', '--name-only', mergeBaseResponse!, 'HEAD']));
+  });
+
+  test('use correct base sha if specified', () async {
+    const String customBaseSha = 'aklsjdcaskf12312';
+    gitDiffResponse = '''
+file1/pubspec.yaml
+file2/file2.cc
+''';
+    final GitVersionFinder finder = GitVersionFinder(gitDir, customBaseSha);
+    await finder.getChangedFiles();
+    verify(gitDir
+        .runCommand(<String>['diff', '--name-only', customBaseSha, 'HEAD']));
+  });
+}
+
+class MockProcessResult extends Mock implements ProcessResult {}
diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart
new file mode 100644
index 0000000..58d202e
--- /dev/null
+++ b/script/tool/test/common/plugin_command_test.dart
@@ -0,0 +1,387 @@
+// 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';
+
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:flutter_plugin_tools/src/common/plugin_command.dart';
+import 'package:flutter_plugin_tools/src/common/process_runner.dart';
+import 'package:git/git.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import '../util.dart';
+import 'plugin_command_test.mocks.dart';
+
+@GenerateMocks(<Type>[GitDir])
+void main() {
+  late RecordingProcessRunner processRunner;
+  late CommandRunner<void> runner;
+  late FileSystem fileSystem;
+  late Directory packagesDir;
+  late Directory thirdPartyPackagesDir;
+  late List<String> plugins;
+  late List<List<String>?> gitDirCommands;
+  late String gitDiffResponse;
+
+  setUp(() {
+    fileSystem = MemoryFileSystem();
+    packagesDir = createPackagesDirectory(fileSystem: fileSystem);
+    thirdPartyPackagesDir = packagesDir.parent
+        .childDirectory('third_party')
+        .childDirectory('packages');
+
+    gitDirCommands = <List<String>?>[];
+    gitDiffResponse = '';
+    final MockGitDir gitDir = MockGitDir();
+    when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError')))
+        .thenAnswer((Invocation invocation) {
+      gitDirCommands.add(invocation.positionalArguments[0] as List<String>?);
+      final MockProcessResult mockProcessResult = MockProcessResult();
+      if (invocation.positionalArguments[0][0] == 'diff') {
+        when<String?>(mockProcessResult.stdout as String?)
+            .thenReturn(gitDiffResponse);
+      }
+      return Future<ProcessResult>.value(mockProcessResult);
+    });
+    processRunner = RecordingProcessRunner();
+    plugins = <String>[];
+    final SamplePluginCommand samplePluginCommand = SamplePluginCommand(
+      plugins,
+      packagesDir,
+      processRunner: processRunner,
+      gitDir: gitDir,
+    );
+    runner =
+        CommandRunner<void>('common_command', 'Test for common functionality');
+    runner.addCommand(samplePluginCommand);
+  });
+
+  group('plugin iteration', () {
+    test('all plugins from file system', () async {
+      final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+      await runner.run(<String>['sample']);
+      expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+    });
+
+    test('all plugins includes third_party/packages', () async {
+      final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+      final Directory plugin3 =
+          createFakePlugin('plugin3', thirdPartyPackagesDir);
+      await runner.run(<String>['sample']);
+      expect(plugins,
+          unorderedEquals(<String>[plugin1.path, plugin2.path, plugin3.path]));
+    });
+
+    test('exclude plugins when plugins flag is specified', () async {
+      createFakePlugin('plugin1', packagesDir);
+      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+      await runner.run(
+          <String>['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']);
+      expect(plugins, unorderedEquals(<String>[plugin2.path]));
+    });
+
+    test('exclude plugins when plugins flag isn\'t specified', () async {
+      createFakePlugin('plugin1', packagesDir);
+      createFakePlugin('plugin2', packagesDir);
+      await runner.run(<String>['sample', '--exclude=plugin1,plugin2']);
+      expect(plugins, unorderedEquals(<String>[]));
+    });
+
+    test('exclude federated plugins when plugins flag is specified', () async {
+      createFakePlugin('plugin1', packagesDir,
+          parentDirectoryName: 'federated');
+      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+      await runner.run(<String>[
+        'sample',
+        '--plugins=federated/plugin1,plugin2',
+        '--exclude=federated/plugin1'
+      ]);
+      expect(plugins, unorderedEquals(<String>[plugin2.path]));
+    });
+
+    test('exclude entire federated plugins when plugins flag is specified',
+        () async {
+      createFakePlugin('plugin1', packagesDir,
+          parentDirectoryName: 'federated');
+      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+      await runner.run(<String>[
+        'sample',
+        '--plugins=federated/plugin1,plugin2',
+        '--exclude=federated'
+      ]);
+      expect(plugins, unorderedEquals(<String>[plugin2.path]));
+    });
+
+    group('test run-on-changed-packages', () {
+      test('all plugins should be tested if there are no changes.', () async {
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test(
+          'all plugins should be tested if there are no plugin related changes.',
+          () async {
+        gitDiffResponse = 'AUTHORS';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('all plugins should be tested if .cirrus.yml changes.', () async {
+        gitDiffResponse = '''
+.cirrus.yml
+packages/plugin1/CHANGELOG
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('all plugins should be tested if .ci.yaml changes', () async {
+        gitDiffResponse = '''
+.ci.yaml
+packages/plugin1/CHANGELOG
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('all plugins should be tested if anything in .ci/ changes',
+          () async {
+        gitDiffResponse = '''
+.ci/Dockerfile
+packages/plugin1/CHANGELOG
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('all plugins should be tested if anything in script changes.',
+          () async {
+        gitDiffResponse = '''
+script/tool_runner.sh
+packages/plugin1/CHANGELOG
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('all plugins should be tested if the root analysis options change.',
+          () async {
+        gitDiffResponse = '''
+analysis_options.yaml
+packages/plugin1/CHANGELOG
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('all plugins should be tested if formatting options change.',
+          () async {
+        gitDiffResponse = '''
+.clang-format
+packages/plugin1/CHANGELOG
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('Only changed plugin should be tested.', () async {
+        gitDiffResponse = 'packages/plugin1/plugin1.dart';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path]));
+      });
+
+      test('multiple files in one plugin should also test the plugin',
+          () async {
+        gitDiffResponse = '''
+packages/plugin1/plugin1.dart
+packages/plugin1/ios/plugin1.m
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        createFakePlugin('plugin2', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path]));
+      });
+
+      test('multiple plugins changed should test all the changed plugins',
+          () async {
+        gitDiffResponse = '''
+packages/plugin1/plugin1.dart
+packages/plugin2/ios/plugin2.m
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        createFakePlugin('plugin3', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test(
+          'multiple plugins inside the same plugin group changed should output the plugin group name',
+          () async {
+        gitDiffResponse = '''
+packages/plugin1/plugin1/plugin1.dart
+packages/plugin1/plugin1_platform_interface/plugin1_platform_interface.dart
+packages/plugin1/plugin1_web/plugin1_web.dart
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir,
+            parentDirectoryName: 'plugin1');
+        createFakePlugin('plugin2', packagesDir);
+        createFakePlugin('plugin3', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path]));
+      });
+
+      test('--plugins flag overrides the behavior of --run-on-changed-packages',
+          () async {
+        gitDiffResponse = '''
+packages/plugin1/plugin1.dart
+packages/plugin2/ios/plugin2.m
+packages/plugin3/plugin3.dart
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir,
+            parentDirectoryName: 'plugin1');
+        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
+        createFakePlugin('plugin3', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--plugins=plugin1,plugin2',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+      });
+
+      test('--exclude flag works with --run-on-changed-packages', () async {
+        gitDiffResponse = '''
+packages/plugin1/plugin1.dart
+packages/plugin2/ios/plugin2.m
+packages/plugin3/plugin3.dart
+''';
+        final Directory plugin1 = createFakePlugin('plugin1', packagesDir,
+            parentDirectoryName: 'plugin1');
+        createFakePlugin('plugin2', packagesDir);
+        createFakePlugin('plugin3', packagesDir);
+        await runner.run(<String>[
+          'sample',
+          '--exclude=plugin2,plugin3',
+          '--base-sha=master',
+          '--run-on-changed-packages'
+        ]);
+
+        expect(plugins, unorderedEquals(<String>[plugin1.path]));
+      });
+    });
+  });
+}
+
+class SamplePluginCommand extends PluginCommand {
+  SamplePluginCommand(
+    this._plugins,
+    Directory packagesDir, {
+    ProcessRunner processRunner = const ProcessRunner(),
+    GitDir? gitDir,
+  }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir);
+
+  final List<String> _plugins;
+
+  @override
+  final String name = 'sample';
+
+  @override
+  final String description = 'sample command';
+
+  @override
+  Future<void> run() async {
+    await for (final Directory package in getPlugins()) {
+      _plugins.add(package.path);
+    }
+  }
+}
+
+class MockProcessResult extends Mock implements ProcessResult {}
diff --git a/script/tool/test/common_test.mocks.dart b/script/tool/test/common/plugin_command_test.mocks.dart
similarity index 100%
rename from script/tool/test/common_test.mocks.dart
rename to script/tool/test/common/plugin_command_test.mocks.dart
diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart
new file mode 100644
index 0000000..aaa8501
--- /dev/null
+++ b/script/tool/test/common/plugin_utils_test.dart
@@ -0,0 +1,210 @@
+// 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 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:flutter_plugin_tools/src/common/plugin_utils.dart';
+import 'package:test/test.dart';
+
+import '../util.dart';
+
+void main() {
+  late FileSystem fileSystem;
+  late Directory packagesDir;
+
+  setUp(() {
+    fileSystem = MemoryFileSystem();
+    packagesDir = createPackagesDirectory(fileSystem: fileSystem);
+  });
+
+  group('pluginSupportsPlatform', () {
+    test('no platforms', () async {
+      final Directory plugin = createFakePlugin('plugin', packagesDir);
+
+      expect(pluginSupportsPlatform('android', plugin), isFalse);
+      expect(pluginSupportsPlatform('ios', plugin), isFalse);
+      expect(pluginSupportsPlatform('linux', plugin), isFalse);
+      expect(pluginSupportsPlatform('macos', plugin), isFalse);
+      expect(pluginSupportsPlatform('web', plugin), isFalse);
+      expect(pluginSupportsPlatform('windows', plugin), isFalse);
+    });
+
+    test('all platforms', () async {
+      final Directory plugin = createFakePlugin(
+        'plugin',
+        packagesDir,
+        isAndroidPlugin: true,
+        isIosPlugin: true,
+        isLinuxPlugin: true,
+        isMacOsPlugin: true,
+        isWebPlugin: true,
+        isWindowsPlugin: true,
+      );
+
+      expect(pluginSupportsPlatform('android', plugin), isTrue);
+      expect(pluginSupportsPlatform('ios', plugin), isTrue);
+      expect(pluginSupportsPlatform('linux', plugin), isTrue);
+      expect(pluginSupportsPlatform('macos', plugin), isTrue);
+      expect(pluginSupportsPlatform('web', plugin), isTrue);
+      expect(pluginSupportsPlatform('windows', plugin), isTrue);
+    });
+
+    test('some platforms', () async {
+      final Directory plugin = createFakePlugin(
+        'plugin',
+        packagesDir,
+        isAndroidPlugin: true,
+        isIosPlugin: false,
+        isLinuxPlugin: true,
+        isMacOsPlugin: false,
+        isWebPlugin: true,
+        isWindowsPlugin: false,
+      );
+
+      expect(pluginSupportsPlatform('android', plugin), isTrue);
+      expect(pluginSupportsPlatform('ios', plugin), isFalse);
+      expect(pluginSupportsPlatform('linux', plugin), isTrue);
+      expect(pluginSupportsPlatform('macos', plugin), isFalse);
+      expect(pluginSupportsPlatform('web', plugin), isTrue);
+      expect(pluginSupportsPlatform('windows', plugin), isFalse);
+    });
+
+    test('inline plugins are only detected as inline', () async {
+      // createFakePlugin makes non-federated pubspec entries.
+      final Directory plugin = createFakePlugin(
+        'plugin',
+        packagesDir,
+        isAndroidPlugin: true,
+        isIosPlugin: true,
+        isLinuxPlugin: true,
+        isMacOsPlugin: true,
+        isWebPlugin: true,
+        isWindowsPlugin: true,
+      );
+
+      expect(
+          pluginSupportsPlatform('android', plugin,
+              requiredMode: PlatformSupport.inline),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('android', plugin,
+              requiredMode: PlatformSupport.federated),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('ios', plugin,
+              requiredMode: PlatformSupport.inline),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('ios', plugin,
+              requiredMode: PlatformSupport.federated),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('linux', plugin,
+              requiredMode: PlatformSupport.inline),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('linux', plugin,
+              requiredMode: PlatformSupport.federated),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('macos', plugin,
+              requiredMode: PlatformSupport.inline),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('macos', plugin,
+              requiredMode: PlatformSupport.federated),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('web', plugin,
+              requiredMode: PlatformSupport.inline),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('web', plugin,
+              requiredMode: PlatformSupport.federated),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('windows', plugin,
+              requiredMode: PlatformSupport.inline),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('windows', plugin,
+              requiredMode: PlatformSupport.federated),
+          isFalse);
+    });
+
+    test('federated plugins are only detected as federated', () async {
+      const String pluginName = 'plugin';
+      final Directory plugin = createFakePlugin(
+        pluginName,
+        packagesDir,
+        isAndroidPlugin: true,
+        isIosPlugin: true,
+        isLinuxPlugin: true,
+        isMacOsPlugin: true,
+        isWebPlugin: true,
+        isWindowsPlugin: true,
+      );
+
+      createFakePubspec(
+        plugin,
+        name: pluginName,
+        androidSupport: PlatformSupport.federated,
+        iosSupport: PlatformSupport.federated,
+        linuxSupport: PlatformSupport.federated,
+        macosSupport: PlatformSupport.federated,
+        webSupport: PlatformSupport.federated,
+        windowsSupport: PlatformSupport.federated,
+      );
+
+      expect(
+          pluginSupportsPlatform('android', plugin,
+              requiredMode: PlatformSupport.federated),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('android', plugin,
+              requiredMode: PlatformSupport.inline),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('ios', plugin,
+              requiredMode: PlatformSupport.federated),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('ios', plugin,
+              requiredMode: PlatformSupport.inline),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('linux', plugin,
+              requiredMode: PlatformSupport.federated),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('linux', plugin,
+              requiredMode: PlatformSupport.inline),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('macos', plugin,
+              requiredMode: PlatformSupport.federated),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('macos', plugin,
+              requiredMode: PlatformSupport.inline),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('web', plugin,
+              requiredMode: PlatformSupport.federated),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('web', plugin,
+              requiredMode: PlatformSupport.inline),
+          isFalse);
+      expect(
+          pluginSupportsPlatform('windows', plugin,
+              requiredMode: PlatformSupport.federated),
+          isTrue);
+      expect(
+          pluginSupportsPlatform('windows', plugin,
+              requiredMode: PlatformSupport.inline),
+          isFalse);
+    });
+  });
+}
diff --git a/script/tool/test/common/pub_version_finder_test.dart b/script/tool/test/common/pub_version_finder_test.dart
new file mode 100644
index 0000000..7d8658a
--- /dev/null
+++ b/script/tool/test/common/pub_version_finder_test.dart
@@ -0,0 +1,89 @@
+// 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:convert';
+import 'dart:io';
+
+import 'package:flutter_plugin_tools/src/common/pub_version_finder.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:mockito/mockito.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('Package does not exist.', () async {
+    final MockClient mockClient = MockClient((http.Request request) async {
+      return http.Response('', 404);
+    });
+    final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient);
+    final PubVersionFinderResponse response =
+        await finder.getPackageVersion(package: 'some_package');
+
+    expect(response.versions, isEmpty);
+    expect(response.result, PubVersionFinderResult.noPackageFound);
+    expect(response.httpResponse.statusCode, 404);
+    expect(response.httpResponse.body, '');
+  });
+
+  test('HTTP error when getting versions from pub', () async {
+    final MockClient mockClient = MockClient((http.Request request) async {
+      return http.Response('', 400);
+    });
+    final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient);
+    final PubVersionFinderResponse response =
+        await finder.getPackageVersion(package: 'some_package');
+
+    expect(response.versions, isEmpty);
+    expect(response.result, PubVersionFinderResult.fail);
+    expect(response.httpResponse.statusCode, 400);
+    expect(response.httpResponse.body, '');
+  });
+
+  test('Get a correct list of versions when http response is OK.', () async {
+    const Map<String, dynamic> httpResponse = <String, dynamic>{
+      'name': 'some_package',
+      'versions': <String>[
+        '0.0.1',
+        '0.0.2',
+        '0.0.2+2',
+        '0.1.1',
+        '0.0.1+1',
+        '0.1.0',
+        '0.2.0',
+        '0.1.0+1',
+        '0.0.2+1',
+        '2.0.0',
+        '1.2.0',
+        '1.0.0',
+      ],
+    };
+    final MockClient mockClient = MockClient((http.Request request) async {
+      return http.Response(json.encode(httpResponse), 200);
+    });
+    final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient);
+    final PubVersionFinderResponse response =
+        await finder.getPackageVersion(package: 'some_package');
+
+    expect(response.versions, <Version>[
+      Version.parse('2.0.0'),
+      Version.parse('1.2.0'),
+      Version.parse('1.0.0'),
+      Version.parse('0.2.0'),
+      Version.parse('0.1.1'),
+      Version.parse('0.1.0+1'),
+      Version.parse('0.1.0'),
+      Version.parse('0.0.2+2'),
+      Version.parse('0.0.2+1'),
+      Version.parse('0.0.2'),
+      Version.parse('0.0.1+1'),
+      Version.parse('0.0.1'),
+    ]);
+    expect(response.result, PubVersionFinderResult.success);
+    expect(response.httpResponse.statusCode, 200);
+    expect(response.httpResponse.body, json.encode(httpResponse));
+  });
+}
+
+class MockProcessResult extends Mock implements ProcessResult {}
diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart
deleted file mode 100644
index a51182d..0000000
--- a/script/tool/test/common_test.dart
+++ /dev/null
@@ -1,740 +0,0 @@
-// 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';
-
-import 'package:args/command_runner.dart';
-import 'package:file/file.dart';
-import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
-import 'package:git/git.dart';
-import 'package:http/http.dart' as http;
-import 'package:http/testing.dart';
-import 'package:mockito/annotations.dart';
-import 'package:mockito/mockito.dart';
-import 'package:pub_semver/pub_semver.dart';
-import 'package:test/test.dart';
-
-import 'common_test.mocks.dart';
-import 'util.dart';
-
-@GenerateMocks(<Type>[GitDir])
-void main() {
-  late RecordingProcessRunner processRunner;
-  late CommandRunner<void> runner;
-  late FileSystem fileSystem;
-  late Directory packagesDir;
-  late Directory thirdPartyPackagesDir;
-  late List<String> plugins;
-  late List<List<String>?> gitDirCommands;
-  late String gitDiffResponse;
-
-  setUp(() {
-    fileSystem = MemoryFileSystem();
-    packagesDir = createPackagesDirectory(fileSystem: fileSystem);
-    thirdPartyPackagesDir = packagesDir.parent
-        .childDirectory('third_party')
-        .childDirectory('packages');
-
-    gitDirCommands = <List<String>?>[];
-    gitDiffResponse = '';
-    final MockGitDir gitDir = MockGitDir();
-    when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError')))
-        .thenAnswer((Invocation invocation) {
-      gitDirCommands.add(invocation.positionalArguments[0] as List<String>?);
-      final MockProcessResult mockProcessResult = MockProcessResult();
-      if (invocation.positionalArguments[0][0] == 'diff') {
-        when<String?>(mockProcessResult.stdout as String?)
-            .thenReturn(gitDiffResponse);
-      }
-      return Future<ProcessResult>.value(mockProcessResult);
-    });
-    processRunner = RecordingProcessRunner();
-    plugins = <String>[];
-    final SamplePluginCommand samplePluginCommand = SamplePluginCommand(
-      plugins,
-      packagesDir,
-      processRunner: processRunner,
-      gitDir: gitDir,
-    );
-    runner =
-        CommandRunner<void>('common_command', 'Test for common functionality');
-    runner.addCommand(samplePluginCommand);
-  });
-
-  group('plugin iteration', () {
-    test('all plugins from file system', () async {
-      final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-      await runner.run(<String>['sample']);
-      expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-    });
-
-    test('all plugins includes third_party/packages', () async {
-      final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-      final Directory plugin3 =
-          createFakePlugin('plugin3', thirdPartyPackagesDir);
-      await runner.run(<String>['sample']);
-      expect(plugins,
-          unorderedEquals(<String>[plugin1.path, plugin2.path, plugin3.path]));
-    });
-
-    test('exclude plugins when plugins flag is specified', () async {
-      createFakePlugin('plugin1', packagesDir);
-      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-      await runner.run(
-          <String>['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']);
-      expect(plugins, unorderedEquals(<String>[plugin2.path]));
-    });
-
-    test('exclude plugins when plugins flag isn\'t specified', () async {
-      createFakePlugin('plugin1', packagesDir);
-      createFakePlugin('plugin2', packagesDir);
-      await runner.run(<String>['sample', '--exclude=plugin1,plugin2']);
-      expect(plugins, unorderedEquals(<String>[]));
-    });
-
-    test('exclude federated plugins when plugins flag is specified', () async {
-      createFakePlugin('plugin1', packagesDir,
-          parentDirectoryName: 'federated');
-      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-      await runner.run(<String>[
-        'sample',
-        '--plugins=federated/plugin1,plugin2',
-        '--exclude=federated/plugin1'
-      ]);
-      expect(plugins, unorderedEquals(<String>[plugin2.path]));
-    });
-
-    test('exclude entire federated plugins when plugins flag is specified',
-        () async {
-      createFakePlugin('plugin1', packagesDir,
-          parentDirectoryName: 'federated');
-      final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-      await runner.run(<String>[
-        'sample',
-        '--plugins=federated/plugin1,plugin2',
-        '--exclude=federated'
-      ]);
-      expect(plugins, unorderedEquals(<String>[plugin2.path]));
-    });
-
-    group('test run-on-changed-packages', () {
-      test('all plugins should be tested if there are no changes.', () async {
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test(
-          'all plugins should be tested if there are no plugin related changes.',
-          () async {
-        gitDiffResponse = 'AUTHORS';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('all plugins should be tested if .cirrus.yml changes.', () async {
-        gitDiffResponse = '''
-.cirrus.yml
-packages/plugin1/CHANGELOG
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('all plugins should be tested if .ci.yaml changes', () async {
-        gitDiffResponse = '''
-.ci.yaml
-packages/plugin1/CHANGELOG
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('all plugins should be tested if anything in .ci/ changes',
-          () async {
-        gitDiffResponse = '''
-.ci/Dockerfile
-packages/plugin1/CHANGELOG
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('all plugins should be tested if anything in script changes.',
-          () async {
-        gitDiffResponse = '''
-script/tool_runner.sh
-packages/plugin1/CHANGELOG
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('all plugins should be tested if the root analysis options change.',
-          () async {
-        gitDiffResponse = '''
-analysis_options.yaml
-packages/plugin1/CHANGELOG
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('all plugins should be tested if formatting options change.',
-          () async {
-        gitDiffResponse = '''
-.clang-format
-packages/plugin1/CHANGELOG
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('Only changed plugin should be tested.', () async {
-        gitDiffResponse = 'packages/plugin1/plugin1.dart';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path]));
-      });
-
-      test('multiple files in one plugin should also test the plugin',
-          () async {
-        gitDiffResponse = '''
-packages/plugin1/plugin1.dart
-packages/plugin1/ios/plugin1.m
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        createFakePlugin('plugin2', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path]));
-      });
-
-      test('multiple plugins changed should test all the changed plugins',
-          () async {
-        gitDiffResponse = '''
-packages/plugin1/plugin1.dart
-packages/plugin2/ios/plugin2.m
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir);
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        createFakePlugin('plugin3', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test(
-          'multiple plugins inside the same plugin group changed should output the plugin group name',
-          () async {
-        gitDiffResponse = '''
-packages/plugin1/plugin1/plugin1.dart
-packages/plugin1/plugin1_platform_interface/plugin1_platform_interface.dart
-packages/plugin1/plugin1_web/plugin1_web.dart
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir,
-            parentDirectoryName: 'plugin1');
-        createFakePlugin('plugin2', packagesDir);
-        createFakePlugin('plugin3', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path]));
-      });
-
-      test('--plugins flag overrides the behavior of --run-on-changed-packages',
-          () async {
-        gitDiffResponse = '''
-packages/plugin1/plugin1.dart
-packages/plugin2/ios/plugin2.m
-packages/plugin3/plugin3.dart
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir,
-            parentDirectoryName: 'plugin1');
-        final Directory plugin2 = createFakePlugin('plugin2', packagesDir);
-        createFakePlugin('plugin3', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--plugins=plugin1,plugin2',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
-      });
-
-      test('--exclude flag works with --run-on-changed-packages', () async {
-        gitDiffResponse = '''
-packages/plugin1/plugin1.dart
-packages/plugin2/ios/plugin2.m
-packages/plugin3/plugin3.dart
-''';
-        final Directory plugin1 = createFakePlugin('plugin1', packagesDir,
-            parentDirectoryName: 'plugin1');
-        createFakePlugin('plugin2', packagesDir);
-        createFakePlugin('plugin3', packagesDir);
-        await runner.run(<String>[
-          'sample',
-          '--exclude=plugin2,plugin3',
-          '--base-sha=master',
-          '--run-on-changed-packages'
-        ]);
-
-        expect(plugins, unorderedEquals(<String>[plugin1.path]));
-      });
-    });
-  });
-
-  group('$GitVersionFinder', () {
-    late FileSystem fileSystem;
-    late List<List<String>?> gitDirCommands;
-    late String gitDiffResponse;
-    String? mergeBaseResponse;
-    late MockGitDir gitDir;
-
-    setUp(() {
-      fileSystem = MemoryFileSystem();
-      createPackagesDirectory(fileSystem: fileSystem);
-      gitDirCommands = <List<String>?>[];
-      gitDiffResponse = '';
-      gitDir = MockGitDir();
-      when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError')))
-          .thenAnswer((Invocation invocation) {
-        gitDirCommands.add(invocation.positionalArguments[0] as List<String>?);
-        final MockProcessResult mockProcessResult = MockProcessResult();
-        if (invocation.positionalArguments[0][0] == 'diff') {
-          when<String?>(mockProcessResult.stdout as String?)
-              .thenReturn(gitDiffResponse);
-        } else if (invocation.positionalArguments[0][0] == 'merge-base') {
-          when<String?>(mockProcessResult.stdout as String?)
-              .thenReturn(mergeBaseResponse);
-        }
-        return Future<ProcessResult>.value(mockProcessResult);
-      });
-      processRunner = RecordingProcessRunner();
-    });
-
-    test('No git diff should result no files changed', () async {
-      final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha');
-      final List<String> changedFiles = await finder.getChangedFiles();
-
-      expect(changedFiles, isEmpty);
-    });
-
-    test('get correct files changed based on git diff', () async {
-      gitDiffResponse = '''
-file1/file1.cc
-file2/file2.cc
-''';
-      final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha');
-      final List<String> changedFiles = await finder.getChangedFiles();
-
-      expect(
-          changedFiles, equals(<String>['file1/file1.cc', 'file2/file2.cc']));
-    });
-
-    test('get correct pubspec change based on git diff', () async {
-      gitDiffResponse = '''
-file1/pubspec.yaml
-file2/file2.cc
-''';
-      final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha');
-      final List<String> changedFiles = await finder.getChangedPubSpecs();
-
-      expect(changedFiles, equals(<String>['file1/pubspec.yaml']));
-    });
-
-    test('use correct base sha if not specified', () async {
-      mergeBaseResponse = 'shaqwiueroaaidf12312jnadf123nd';
-      gitDiffResponse = '''
-file1/pubspec.yaml
-file2/file2.cc
-''';
-
-      final GitVersionFinder finder = GitVersionFinder(gitDir, null);
-      await finder.getChangedFiles();
-      verify(gitDir.runCommand(
-          <String>['diff', '--name-only', mergeBaseResponse!, 'HEAD']));
-    });
-
-    test('use correct base sha if specified', () async {
-      const String customBaseSha = 'aklsjdcaskf12312';
-      gitDiffResponse = '''
-file1/pubspec.yaml
-file2/file2.cc
-''';
-      final GitVersionFinder finder = GitVersionFinder(gitDir, customBaseSha);
-      await finder.getChangedFiles();
-      verify(gitDir
-          .runCommand(<String>['diff', '--name-only', customBaseSha, 'HEAD']));
-    });
-  });
-
-  group('$PubVersionFinder', () {
-    test('Package does not exist.', () async {
-      final MockClient mockClient = MockClient((http.Request request) async {
-        return http.Response('', 404);
-      });
-      final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient);
-      final PubVersionFinderResponse response =
-          await finder.getPackageVersion(package: 'some_package');
-
-      expect(response.versions, isEmpty);
-      expect(response.result, PubVersionFinderResult.noPackageFound);
-      expect(response.httpResponse.statusCode, 404);
-      expect(response.httpResponse.body, '');
-    });
-
-    test('HTTP error when getting versions from pub', () async {
-      final MockClient mockClient = MockClient((http.Request request) async {
-        return http.Response('', 400);
-      });
-      final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient);
-      final PubVersionFinderResponse response =
-          await finder.getPackageVersion(package: 'some_package');
-
-      expect(response.versions, isEmpty);
-      expect(response.result, PubVersionFinderResult.fail);
-      expect(response.httpResponse.statusCode, 400);
-      expect(response.httpResponse.body, '');
-    });
-
-    test('Get a correct list of versions when http response is OK.', () async {
-      const Map<String, dynamic> httpResponse = <String, dynamic>{
-        'name': 'some_package',
-        'versions': <String>[
-          '0.0.1',
-          '0.0.2',
-          '0.0.2+2',
-          '0.1.1',
-          '0.0.1+1',
-          '0.1.0',
-          '0.2.0',
-          '0.1.0+1',
-          '0.0.2+1',
-          '2.0.0',
-          '1.2.0',
-          '1.0.0',
-        ],
-      };
-      final MockClient mockClient = MockClient((http.Request request) async {
-        return http.Response(json.encode(httpResponse), 200);
-      });
-      final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient);
-      final PubVersionFinderResponse response =
-          await finder.getPackageVersion(package: 'some_package');
-
-      expect(response.versions, <Version>[
-        Version.parse('2.0.0'),
-        Version.parse('1.2.0'),
-        Version.parse('1.0.0'),
-        Version.parse('0.2.0'),
-        Version.parse('0.1.1'),
-        Version.parse('0.1.0+1'),
-        Version.parse('0.1.0'),
-        Version.parse('0.0.2+2'),
-        Version.parse('0.0.2+1'),
-        Version.parse('0.0.2'),
-        Version.parse('0.0.1+1'),
-        Version.parse('0.0.1'),
-      ]);
-      expect(response.result, PubVersionFinderResult.success);
-      expect(response.httpResponse.statusCode, 200);
-      expect(response.httpResponse.body, json.encode(httpResponse));
-    });
-  });
-
-  group('pluginSupportsPlatform', () {
-    test('no platforms', () async {
-      final Directory plugin = createFakePlugin('plugin', packagesDir);
-
-      expect(pluginSupportsPlatform('android', plugin), isFalse);
-      expect(pluginSupportsPlatform('ios', plugin), isFalse);
-      expect(pluginSupportsPlatform('linux', plugin), isFalse);
-      expect(pluginSupportsPlatform('macos', plugin), isFalse);
-      expect(pluginSupportsPlatform('web', plugin), isFalse);
-      expect(pluginSupportsPlatform('windows', plugin), isFalse);
-    });
-
-    test('all platforms', () async {
-      final Directory plugin = createFakePlugin(
-        'plugin',
-        packagesDir,
-        isAndroidPlugin: true,
-        isIosPlugin: true,
-        isLinuxPlugin: true,
-        isMacOsPlugin: true,
-        isWebPlugin: true,
-        isWindowsPlugin: true,
-      );
-
-      expect(pluginSupportsPlatform('android', plugin), isTrue);
-      expect(pluginSupportsPlatform('ios', plugin), isTrue);
-      expect(pluginSupportsPlatform('linux', plugin), isTrue);
-      expect(pluginSupportsPlatform('macos', plugin), isTrue);
-      expect(pluginSupportsPlatform('web', plugin), isTrue);
-      expect(pluginSupportsPlatform('windows', plugin), isTrue);
-    });
-
-    test('some platforms', () async {
-      final Directory plugin = createFakePlugin(
-        'plugin',
-        packagesDir,
-        isAndroidPlugin: true,
-        isIosPlugin: false,
-        isLinuxPlugin: true,
-        isMacOsPlugin: false,
-        isWebPlugin: true,
-        isWindowsPlugin: false,
-      );
-
-      expect(pluginSupportsPlatform('android', plugin), isTrue);
-      expect(pluginSupportsPlatform('ios', plugin), isFalse);
-      expect(pluginSupportsPlatform('linux', plugin), isTrue);
-      expect(pluginSupportsPlatform('macos', plugin), isFalse);
-      expect(pluginSupportsPlatform('web', plugin), isTrue);
-      expect(pluginSupportsPlatform('windows', plugin), isFalse);
-    });
-
-    test('inline plugins are only detected as inline', () async {
-      // createFakePlugin makes non-federated pubspec entries.
-      final Directory plugin = createFakePlugin(
-        'plugin',
-        packagesDir,
-        isAndroidPlugin: true,
-        isIosPlugin: true,
-        isLinuxPlugin: true,
-        isMacOsPlugin: true,
-        isWebPlugin: true,
-        isWindowsPlugin: true,
-      );
-
-      expect(
-          pluginSupportsPlatform('android', plugin,
-              requiredMode: PlatformSupport.inline),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('android', plugin,
-              requiredMode: PlatformSupport.federated),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('ios', plugin,
-              requiredMode: PlatformSupport.inline),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('ios', plugin,
-              requiredMode: PlatformSupport.federated),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('linux', plugin,
-              requiredMode: PlatformSupport.inline),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('linux', plugin,
-              requiredMode: PlatformSupport.federated),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('macos', plugin,
-              requiredMode: PlatformSupport.inline),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('macos', plugin,
-              requiredMode: PlatformSupport.federated),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('web', plugin,
-              requiredMode: PlatformSupport.inline),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('web', plugin,
-              requiredMode: PlatformSupport.federated),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('windows', plugin,
-              requiredMode: PlatformSupport.inline),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('windows', plugin,
-              requiredMode: PlatformSupport.federated),
-          isFalse);
-    });
-
-    test('federated plugins are only detected as federated', () async {
-      const String pluginName = 'plugin';
-      final Directory plugin = createFakePlugin(
-        pluginName,
-        packagesDir,
-        isAndroidPlugin: true,
-        isIosPlugin: true,
-        isLinuxPlugin: true,
-        isMacOsPlugin: true,
-        isWebPlugin: true,
-        isWindowsPlugin: true,
-      );
-
-      createFakePubspec(
-        plugin,
-        name: pluginName,
-        androidSupport: PlatformSupport.federated,
-        iosSupport: PlatformSupport.federated,
-        linuxSupport: PlatformSupport.federated,
-        macosSupport: PlatformSupport.federated,
-        webSupport: PlatformSupport.federated,
-        windowsSupport: PlatformSupport.federated,
-      );
-
-      expect(
-          pluginSupportsPlatform('android', plugin,
-              requiredMode: PlatformSupport.federated),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('android', plugin,
-              requiredMode: PlatformSupport.inline),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('ios', plugin,
-              requiredMode: PlatformSupport.federated),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('ios', plugin,
-              requiredMode: PlatformSupport.inline),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('linux', plugin,
-              requiredMode: PlatformSupport.federated),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('linux', plugin,
-              requiredMode: PlatformSupport.inline),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('macos', plugin,
-              requiredMode: PlatformSupport.federated),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('macos', plugin,
-              requiredMode: PlatformSupport.inline),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('web', plugin,
-              requiredMode: PlatformSupport.federated),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('web', plugin,
-              requiredMode: PlatformSupport.inline),
-          isFalse);
-      expect(
-          pluginSupportsPlatform('windows', plugin,
-              requiredMode: PlatformSupport.federated),
-          isTrue);
-      expect(
-          pluginSupportsPlatform('windows', plugin,
-              requiredMode: PlatformSupport.inline),
-          isFalse);
-    });
-  });
-}
-
-class SamplePluginCommand extends PluginCommand {
-  SamplePluginCommand(
-    this._plugins,
-    Directory packagesDir, {
-    ProcessRunner processRunner = const ProcessRunner(),
-    GitDir? gitDir,
-  }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir);
-
-  final List<String> _plugins;
-
-  @override
-  final String name = 'sample';
-
-  @override
-  final String description = 'sample command';
-
-  @override
-  Future<void> run() async {
-    await for (final Directory package in getPlugins()) {
-      _plugins.add(package.path);
-    }
-  }
-}
-
-class MockProcessResult extends Mock implements ProcessResult {}
diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart
index c9a8b9d..9c5bd18 100644
--- a/script/tool/test/drive_examples_command_test.dart
+++ b/script/tool/test/drive_examples_command_test.dart
@@ -5,7 +5,7 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:flutter_plugin_tools/src/drive_examples_command.dart';
 import 'package:path/path.dart' as p;
 import 'package:platform/platform.dart';
diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart
index aa8be17..0bc8f1e 100644
--- a/script/tool/test/firebase_test_lab_test.dart
+++ b/script/tool/test/firebase_test_lab_test.dart
@@ -7,7 +7,7 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:flutter_plugin_tools/src/firebase_test_lab_command.dart';
 import 'package:test/test.dart';
 
diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart
index a874d7d..dfe8d25 100644
--- a/script/tool/test/license_check_command_test.dart
+++ b/script/tool/test/license_check_command_test.dart
@@ -5,7 +5,7 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:flutter_plugin_tools/src/license_check_command.dart';
 import 'package:test/test.dart';
 
diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart
index e572256..c0ccd29 100644
--- a/script/tool/test/publish_check_command_test.dart
+++ b/script/tool/test/publish_check_command_test.dart
@@ -9,7 +9,7 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:flutter_plugin_tools/src/publish_check_command.dart';
 import 'package:http/http.dart' as http;
 import 'package:http/testing.dart';
diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart
index 1cb4245..ef682bf 100644
--- a/script/tool/test/publish_plugin_command_test.dart
+++ b/script/tool/test/publish_plugin_command_test.dart
@@ -9,7 +9,8 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/local.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
+import 'package:flutter_plugin_tools/src/common/process_runner.dart';
 import 'package:flutter_plugin_tools/src/publish_plugin_command.dart';
 import 'package:git/git.dart';
 import 'package:mockito/mockito.dart';
diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart
index 576060d..f5fe6ae 100644
--- a/script/tool/test/pubspec_check_command_test.dart
+++ b/script/tool/test/pubspec_check_command_test.dart
@@ -5,7 +5,7 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:flutter_plugin_tools/src/pubspec_check_command.dart';
 import 'package:test/test.dart';
 
diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart
index c590d8a..79c46fc 100644
--- a/script/tool/test/util.dart
+++ b/script/tool/test/util.dart
@@ -9,7 +9,8 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/plugin_utils.dart';
+import 'package:flutter_plugin_tools/src/common/process_runner.dart';
 import 'package:meta/meta.dart';
 import 'package:quiver/collection.dart';
 
diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart
index 1199c27..a8e7e20 100644
--- a/script/tool/test/version_check_test.dart
+++ b/script/tool/test/version_check_test.dart
@@ -9,7 +9,7 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:flutter_plugin_tools/src/version_check_command.dart';
 import 'package:http/http.dart' as http;
 import 'package:http/testing.dart';
@@ -17,7 +17,7 @@
 import 'package:pub_semver/pub_semver.dart';
 import 'package:test/test.dart';
 
-import 'common_test.mocks.dart';
+import 'common/plugin_command_test.mocks.dart';
 import 'util.dart';
 
 void testAllowedVersion(
diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart
index 8ed8144..c0bd6b5 100644
--- a/script/tool/test/xctest_command_test.dart
+++ b/script/tool/test/xctest_command_test.dart
@@ -7,7 +7,8 @@
 import 'package:args/command_runner.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
+import 'package:flutter_plugin_tools/src/common/plugin_utils.dart';
 import 'package:flutter_plugin_tools/src/xctest_command.dart';
 import 'package:test/test.dart';