blob: 83ef037ae6adbfb230d65946e50a844120542a75 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:file/file.dart';
import 'package:git/git.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';
import 'common/core.dart';
import 'common/git_version_finder.dart';
import 'common/package_command.dart';
import 'common/repository_package.dart';
const int _exitPackageNotFound = 3;
/// Converts all dependencies on target packages to path-based dependencies.
/// This is to allow for pre-publish testing of changes that could affect other
/// packages in the repository. For instance, this allows for catching cases
/// where a non-breaking change to a platform interface package of a federated
/// plugin would cause post-publish analyzer failures in another package of that
/// plugin.
class MakeDepsPathBasedCommand extends PackageCommand {
/// Creates an instance of the command to convert selected dependencies to
/// path-based.
Directory packagesDir, {
GitDir? gitDir,
}) : super(packagesDir, gitDir: gitDir) {
'The names of the packages to convert to path-based dependencies.\n'
'Ignored if --$_targetDependenciesWithNonBreakingUpdatesArg is '
valueHelp: 'some_package');
help: 'Causes all packages that have non-breaking version changes '
'when compared against the git base to be treated as target '
static const String _targetDependenciesArg = 'target-dependencies';
static const String _targetDependenciesWithNonBreakingUpdatesArg =
// The comment to add to temporary dependency overrides.
// Includes a reference to the docs so that reviewers who aren't familiar with
// the federated plugin change process don't think it's a mistake.
static const String _dependencyOverrideWarningComment =
'# See';
final String name = 'make-deps-path-based';
final String description =
'Converts package dependencies to path-based references.';
Future<void> run() async {
final Set<String> targetDependencies =
? await _getNonBreakingUpdatePackages()
: getStringListArg(_targetDependenciesArg).toSet();
if (targetDependencies.isEmpty) {
print('No target dependencies; nothing to do.');
print('Rewriting references to: ${targetDependencies.join(', ')}...');
final Map<String, RepositoryPackage> localDependencyPackages =
final String repoRootPath = (await gitDir).path;
for (final File pubspec in await _getAllPubspecs()) {
final String displayPath = p.posix.joinAll(
path.split(path.relative(pubspec.absolute.path, from: repoRootPath)));
final bool changed = await _addDependencyOverridesIfNecessary(
RepositoryPackage(pubspec.parent), localDependencyPackages);
if (changed) {
print(' Modified $displayPath');
Map<String, RepositoryPackage> _findLocalPackages(Set<String> packageNames) {
final Map<String, RepositoryPackage> targets =
<String, RepositoryPackage>{};
for (final String packageName in packageNames) {
final Directory topLevelCandidate =
// If packages/<packageName>/ exists, then either that directory is the
// package, or packages/<packageName>/<packageName>/ exists and is the
// package (in the case of a federated plugin).
if (topLevelCandidate.existsSync()) {
final Directory appFacingCandidate =
targets[packageName] = RepositoryPackage(appFacingCandidate.existsSync()
? appFacingCandidate
: topLevelCandidate);
// If there is no packages/<packageName> directory, then either the
// packages doesn't exist, or it is a sub-package of a federated plugin.
// If it's the latter, it will be a directory whose name is a prefix.
for (final FileSystemEntity entity in packagesDir.listSync()) {
if (entity is Directory && packageName.startsWith(entity.basename)) {
final Directory subPackageCandidate =
if (subPackageCandidate.existsSync()) {
targets[packageName] = RepositoryPackage(subPackageCandidate);
if (!targets.containsKey(packageName)) {
printError('Unable to find package "$packageName"');
throw ToolExit(_exitPackageNotFound);
return targets;
/// If [pubspecFile] has any dependencies on packages in [localDependencies],
/// adds dependency_overrides entries to redirect them to the local version
/// using path-based dependencies.
/// Returns true if any overrides were added.
/// If [additionalPackagesToOverride] are provided, they will get
/// dependency_overrides even if there is no direct dependency. This is
/// useful for overriding transitive dependencies.
Future<bool> _addDependencyOverridesIfNecessary(
RepositoryPackage package,
Map<String, RepositoryPackage> localDependencies, {
Iterable<String> additionalPackagesToOverride = const <String>{},
}) async {
final String pubspecContents = package.pubspecFile.readAsStringSync();
// Determine the dependencies to be overridden.
final Pubspec pubspec = Pubspec.parse(pubspecContents);
final Iterable<String> combinedDependencies = <String>[
final List<String> packagesToOverride = combinedDependencies
(String packageName) => localDependencies.containsKey(packageName))
// Sort the combined list to avoid sort_pub_dependencies lint violations.
if (packagesToOverride.isEmpty) {
return false;
// Find the relative path to the common base.
final String commonBasePath = packagesDir.path;
final int packageDepth = path
from: commonBasePath))
final List<String> relativeBasePathComponents =
List<String>.filled(packageDepth, '..');
// Add the overrides.
final YamlEditor editablePubspec = YamlEditor(pubspecContents);
final YamlNode root = editablePubspec.parseAt(<String>[]);
const String dependencyOverridesKey = 'dependency_overrides';
// Ensure that there's a `dependencyOverridesKey` entry to update.
if ((root as YamlMap)[dependencyOverridesKey] == null) {
editablePubspec.update(<String>[dependencyOverridesKey], YamlMap());
for (final String packageName in packagesToOverride) {
// Find the relative path from the common base to the local package.
final List<String> repoRelativePathComponents = path.split(path.relative(
from: commonBasePath));
], <String, String>{
'path': p.posix.joinAll(<String>[
// Add the warning if it's not already there.
String newContent = editablePubspec.toString();
if (!newContent.contains(_dependencyOverrideWarningComment)) {
newContent = newContent.replaceFirst('$dependencyOverridesKey:', '''
// Write the new pubspec.
// Update any examples. This is important for cases like integration tests
// of app-facing packages in federated plugins, where the app-facing
// package depends directly on the implementation packages, but the
// example app doesn't. Since integration tests are run in the example app,
// it needs the overrides in order for tests to pass.
for (final RepositoryPackage example in package.getExamples()) {
_addDependencyOverridesIfNecessary(example, localDependencies,
additionalPackagesToOverride: packagesToOverride);
return true;
/// Returns all pubspecs anywhere under the packages directory.
Future<List<File>> _getAllPubspecs() => packagesDir.parent
.list(recursive: true, followLinks: false)
.where((FileSystemEntity entity) =>
entity is File && p.basename(entity.path) == 'pubspec.yaml')
.map((FileSystemEntity file) => file as File)
/// Returns all packages that have non-breaking published changes (i.e., a
/// minor or bugfix version change) relative to the git comparison base.
/// Prints status information about what was checked for ease of auditing logs
/// in CI.
Future<Set<String>> _getNonBreakingUpdatePackages() async {
final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
final String baseSha = await gitVersionFinder.getBaseSha();
print('Finding changed packages relative to "$baseSha"...');
final Set<String> changedPackages = <String>{};
for (final String changedPath in await gitVersionFinder.getChangedFiles()) {
// Git output always uses Posix paths.
final List<String> allComponents = p.posix.split(changedPath);
// Only pubspec changes are potential publishing events.
if (allComponents.last != 'pubspec.yaml' ||
allComponents.contains('example')) {
if (!allComponents.contains(packagesDir.basename)) {
print(' Skipping $changedPath; not in packages directory.');
final RepositoryPackage package =
// Ignored deleted packages, as they won't be published.
if (!package.pubspecFile.existsSync()) {
final String directoryName = p.posix.joinAll(path.split(path.relative(,
from: packagesDir.path)));
print(' Skipping $directoryName; deleted.');
final String packageName = package.parsePubspec().name;
if (!await _hasNonBreakingVersionChange(package)) {
// Log packages that had pubspec changes but weren't included for ease
// of auditing CI.
print(' Skipping $packageName; no non-breaking version change.');
// TODO(stuartmorgan): Remove this special-casing once this tool checks
// for major version differences relative to the dependencies being
// updated rather than the version change in the PR:
if (packageName == 'pigeon') {
print(' Skipping $packageName; see '
return changedPackages;
Future<bool> _hasNonBreakingVersionChange(RepositoryPackage package) async {
final Pubspec pubspec = package.parsePubspec();
if (pubspec.publishTo == 'none') {
return false;
final String pubspecGitPath = p.posix.joinAll(path.split(path.relative(
from: (await gitDir).path)));
final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
final Version? previousVersion =
await gitVersionFinder.getPackageVersion(pubspecGitPath);
if (previousVersion == null) {
// The plugin is new, so nothing can be depending on it yet.
return false;
final Version newVersion = pubspec.version!;
if ((newVersion.major > 0 && newVersion.major != previousVersion.major) ||
(newVersion.major == 0 && newVersion.minor != previousVersion.minor)) {
// Breaking changes aren't targetted since they won't be picked up
// automatically.
return false;
return newVersion != previousVersion;