blob: 10abcd44ae6ecb853e36298859df002b91f7f3e1 [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 'common/core.dart';
import 'common/git_version_finder.dart';
import 'common/package_command.dart';
import 'common/repository_package.dart';
const int _exitPackageNotFound = 3;
const int _exitCannotUpdatePubspec = 4;
enum _RewriteOutcome { changed, noChangesNeeded, alreadyChanged }
/// 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.
static const String _dependencyOverrideWarningComment =
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 _RewriteOutcome outcome = await _addDependencyOverridesIfNecessary(
pubspec, localDependencyPackages);
switch (outcome) {
case _RewriteOutcome.changed:
print(' Modified $displayPath');
case _RewriteOutcome.alreadyChanged:
print(' Skipped $displayPath - Already rewritten');
case _RewriteOutcome.noChangesNeeded:
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.
Future<_RewriteOutcome> _addDependencyOverridesIfNecessary(File pubspecFile,
Map<String, RepositoryPackage> localDependencies) async {
final String pubspecContents = pubspecFile.readAsStringSync();
final Pubspec pubspec = Pubspec.parse(pubspecContents);
// Fail if there are any dependency overrides already, other than ones
// created by this script. If support for that is needed at some point, it
// can be added, but currently it's not and relying on that makes the logic
// here much simpler.
if (pubspec.dependencyOverrides.isNotEmpty) {
if (pubspecContents.contains(_dependencyOverrideWarningComment)) {
return _RewriteOutcome.alreadyChanged;
'Packages with dependency overrides are not currently supported.');
throw ToolExit(_exitCannotUpdatePubspec);
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.isNotEmpty) {
final String commonBasePath = packagesDir.path;
// Find the relative path to the common base.
final int packageDepth = path
from: commonBasePath))
final List<String> relativeBasePathComponents =
List<String>.filled(packageDepth, '..');
// This is done via strings rather than by manipulating the Pubspec and
// then re-serialiazing so that it's a localized change, rather than
// rewriting the whole file (e.g., destroying comments), which could be
// more disruptive for local use.
String newPubspecContents = '''
for (final String packageName in packagesToOverride) {
// Find the relative path from the common base to the local package.
final List<String> repoRelativePathComponents = path.split(
from: commonBasePath));
newPubspecContents += '''
path: ${p.posix.joinAll(<String>[
return _RewriteOutcome.changed;
return _RewriteOutcome.noChangesNeeded;
/// 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.');
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;