downgrade --tighten (#4245)
diff --git a/lib/src/command/downgrade.dart b/lib/src/command/downgrade.dart index 6055d5f..acd40c8 100644 --- a/lib/src/command/downgrade.dart +++ b/lib/src/command/downgrade.dart
@@ -5,10 +5,10 @@ import 'dart:async'; import '../command.dart'; +import '../command_runner.dart'; import '../log.dart' as log; import '../solver.dart'; -/// Handles the `downgrade` pub command. class DowngradeCommand extends PubCommand { @override String get name => 'downgrade'; @@ -23,6 +23,12 @@ @override bool get isOffline => argResults.flag('offline'); + bool get _dryRun => argResults.flag('dry-run'); + + bool get _tighten => argResults.flag('tighten'); + + bool get _example => argResults.flag('example'); + DowngradeCommand() { argParser.addFlag( 'offline', @@ -51,6 +57,13 @@ help: 'Run this in the directory <dir>.', valueHelp: 'dir', ); + + argParser.addFlag( + 'tighten', + help: + 'Updates lower bounds in pubspec.yaml to match the resolved version.', + negatable: false, + ); } @override @@ -62,23 +75,32 @@ ), ); } - var dryRun = argResults.flag('dry-run'); await entrypoint.acquireDependencies( SolveType.downgrade, unlock: argResults.rest, - dryRun: dryRun, + dryRun: _dryRun, ); var example = entrypoint.example; if (argResults.flag('example') && example != null) { await example.acquireDependencies( SolveType.get, unlock: argResults.rest, - dryRun: dryRun, + dryRun: _dryRun, summaryOnly: true, ); } + if (_tighten) { + if (_example && entrypoint.example != null) { + log.warning( + 'Running `downgrade --tighten` only in `${entrypoint.workspaceRoot.dir}`. Run `$topLevelProgram pub upgrade --tighten --directory example/` separately.', + ); + } + final changes = entrypoint.tighten(); + entrypoint.applyChanges(changes, _dryRun); + } + if (isOffline) { log.warning('Warning: Downgrading when offline may not update you to ' 'the oldest versions of your dependencies.');
diff --git a/lib/src/command/upgrade.dart b/lib/src/command/upgrade.dart index 3c40b67..1864aa2 100644 --- a/lib/src/command/upgrade.dart +++ b/lib/src/command/upgrade.dart
@@ -5,7 +5,6 @@ import 'dart:async'; import 'package:pub_semver/pub_semver.dart'; -import 'package:yaml_edit/yaml_edit.dart'; import '../command.dart'; import '../command_runner.dart'; @@ -16,7 +15,6 @@ import '../package_name.dart'; import '../pubspec.dart'; import '../pubspec_utils.dart'; -import '../sdk.dart'; import '../solver.dart'; import '../source/hosted.dart'; import '../utils.dart'; @@ -137,25 +135,14 @@ } else { await _runUpgrade(entrypoint); if (_tighten) { - final changes = tighten( - entrypoint, - entrypoint.lockFile.packages.values.toList(), - ); - if (!_dryRun) { - for (final package in entrypoint.workspaceRoot.transitiveWorkspace) { - final changesForPackage = changes[package]; - if (changesForPackage == null || changesForPackage.isEmpty) { - continue; - } - final newPubspecText = - _updatePubspecText(package, changesForPackage); - - if (changes.isNotEmpty) { - writeTextFile(package.pubspecPath, newPubspecText); - } - } + if (argResults.flag('example') && entrypoint.example != null) { + log.warning( + 'Running `upgrade --tighten` only in `${entrypoint.workspaceRoot.dir}`. Run `$topLevelProgram pub upgrade --tighten --directory example/` separately.', + ); } - _outputChangeSummary(changes); + final changes = + entrypoint.tighten(packagesToUpgrade: _packagesToUpgrade); + entrypoint.applyChanges(changes, _dryRun); } } if (argResults.flag('example') && entrypoint.example != null) { @@ -178,75 +165,6 @@ _showOfflineWarning(); } - /// Returns a list of changes to constraints in [pubspec] updated them to - /// have their lower bound match the version in [packages]. - /// - /// The return value is a mapping from the original package range to the updated. - /// - /// If packages to update where given in [_packagesToUpgrade], only those are - /// tightened. Otherwise all packages are tightened. - /// - /// If a dependency has already been updated in [existingChanges], the update - /// will apply on top of that change (eg. preserving the new upper bound). - Map<Package, Map<PackageRange, PackageRange>> tighten( - Entrypoint entrypoint, - List<PackageId> packages, { - Map<Package, Map<PackageRange, PackageRange>> existingChanges = const {}, - }) { - final result = {...existingChanges}; - if (argResults.flag('example') && entrypoint.example != null) { - log.warning( - 'Running `upgrade --tighten` only in `${entrypoint.workspaceRoot.dir}`. Run `$topLevelProgram pub upgrade --tighten --directory example/` separately.', - ); - } - - final toTighten = <(Package, PackageRange)>[]; - - for (final package in entrypoint.workspaceRoot.transitiveWorkspace) { - if (_packagesToUpgrade.isEmpty) { - for (final range in [ - ...package.dependencies.values, - ...package.devDependencies.values, - ]) { - toTighten.add((package, range)); - } - } else { - for (final packageToUpgrade in _packagesToUpgrade) { - final range = package.dependencies[packageToUpgrade] ?? - package.devDependencies[packageToUpgrade]; - if (range != null) { - toTighten.add((package, range)); - } - } - } - } - - for (final (package, range) in toTighten) { - final changesForPackage = result[package] ??= {}; - final constraint = (changesForPackage[range] ?? range).constraint; - final resolvedVersion = - packages.firstWhere((p) => p.name == range.name).version; - if (range.source is HostedSource && constraint.isAny) { - changesForPackage[range] = range - .toRef() - .withConstraint(VersionConstraint.compatibleWith(resolvedVersion)); - } else if (constraint is VersionRange) { - final min = constraint.min; - if (min != null && min < resolvedVersion) { - changesForPackage[range] = range.toRef().withConstraint( - VersionRange( - min: resolvedVersion, - max: constraint.max, - includeMin: true, - includeMax: constraint.includeMax, - ).asCompatibleWithIfPossible(), - ); - } - } - } - return result; - } - /// Return names of packages to be upgraded, and throws [UsageException] if /// any package names not in the direct dependencies or dev_dependencies are given. /// @@ -346,10 +264,10 @@ return applyChanges(package.pubspec, changes[package] ?? {}); }), ); - changes = tighten( - entrypoint, - solveResult.packages, + changes = entrypoint.tighten( + packagesToUpgrade: _packagesToUpgrade, existingChanges: changes, + packageVersions: solveResult.packages, ); } @@ -361,15 +279,7 @@ final solveType = _packagesToUpgrade.isEmpty ? SolveType.upgrade : SolveType.get; - if (!_dryRun) { - for (final package in entrypoint.workspaceRoot.transitiveWorkspace) { - final changesForPackage = changes[package] ?? {}; - if (changesForPackage.isNotEmpty) { - final newPubspecText = _updatePubspecText(package, changesForPackage); - writeTextFile(package.pubspecPath, newPubspecText); - } - } - } + entrypoint.applyChanges(changes, _dryRun); await entrypoint.withUpdatedRootPubspecs({ for (final MapEntry(key: package, value: changesForPackage) in changes.entries) @@ -380,8 +290,6 @@ precompile: !_dryRun && _precompile, ); - _outputChangeSummary(changes); - // If any of the packages to upgrade are dependency overrides, then we // show a warning. final toUpgradeOverrides = toUpgrade @@ -416,65 +324,6 @@ ); } - /// Loads `pubspec.yaml` of [package] and applies [changes] to its - /// (dev)-dependencies. - /// - /// Returns the updated textual representation using yaml-edit to preserve - /// structure. - String _updatePubspecText( - Package package, - Map<PackageRange, PackageRange> changes, - ) { - ArgumentError.checkNotNull(changes, 'changes'); - final yamlEditor = YamlEditor(readTextFile(package.pubspecPath)); - final deps = package.dependencies.keys; - - for (final change in changes.values) { - final section = - deps.contains(change.name) ? 'dependencies' : 'dev_dependencies'; - yamlEditor.update( - [section, change.name], - pubspecDescription(change, cache, package), - ); - } - return yamlEditor.toString(); - } - - /// Outputs a summary of changes made to `pubspec.yaml`. - void _outputChangeSummary( - Map<Package, Map<PackageRange, PackageRange>> changes, - ) { - if (entrypoint.workspaceRoot.workspaceChildren.isEmpty) { - final changesToWorkspaceRoot = changes[entrypoint.workspaceRoot] ?? {}; - if (changesToWorkspaceRoot.isEmpty) { - final wouldBe = _dryRun ? 'would be made to' : 'to'; - log.message('\nNo changes $wouldBe pubspec.yaml!'); - } else { - final changed = _dryRun ? 'Would change' : 'Changed'; - log.message('\n$changed ${changesToWorkspaceRoot.length} ' - '${pluralize('constraint', changesToWorkspaceRoot.length)} in pubspec.yaml:'); - changesToWorkspaceRoot.forEach((from, to) { - log.message(' ${from.name}: ${from.constraint} -> ${to.constraint}'); - }); - } - } else { - if (changes.isEmpty) { - final wouldBe = _dryRun ? 'would be made to' : 'to'; - log.message('\nNo changes $wouldBe any pubspec.yaml!'); - } - for (final package in entrypoint.workspaceRoot.transitiveWorkspace) { - final changesToPackage = changes[package] ?? {}; - if (changesToPackage.isEmpty) continue; - final changed = _dryRun ? 'Would change' : 'Changed'; - log.message('\n$changed ${changesToPackage.length} ' - '${pluralize('constraint', changesToPackage.length)} in ${package.pubspecPath}:'); - changesToPackage.forEach((from, to) { - log.message(' ${from.name}: ${from.constraint} -> ${to.constraint}'); - }); - } - } - } - void _showOfflineWarning() { if (isOffline) { log.warning('Warning: Upgrading when offline may not update you to the '
diff --git a/lib/src/entrypoint.dart b/lib/src/entrypoint.dart index 60515f4..557c2b0 100644 --- a/lib/src/entrypoint.dart +++ b/lib/src/entrypoint.dart
@@ -13,6 +13,7 @@ import 'package:pub_semver/pub_semver.dart'; import 'package:source_span/source_span.dart'; import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; import 'command_runner.dart'; import 'dart.dart' as dart; @@ -27,12 +28,14 @@ import 'package_graph.dart'; import 'package_name.dart'; import 'pubspec.dart'; +import 'pubspec_utils.dart'; import 'sdk.dart'; import 'sdk/flutter.dart'; import 'solver.dart'; import 'solver/report.dart'; import 'solver/solve_suggestions.dart'; import 'source/cached.dart'; +import 'source/hosted.dart'; import 'source/root.dart'; import 'source/unknown.dart'; import 'system_cache.dart'; @@ -1244,4 +1247,139 @@ } } } + + /// Returns a list of changes to constraints of workspace pubspecs updated to + /// have their lower bound match the version in [packageVersions] (or + /// `this.lockFile`). + /// + /// The return value for each workspace package is a mapping from the original + /// package range to the updated. + /// + /// If packages to update where given in [packagesToUpgrade], only those are + /// tightened. Otherwise all packages are tightened. + /// + /// If a dependency has already been updated in [existingChanges], the update + /// will apply on top of that change (eg. preserving the new upper bound). + Map<Package, Map<PackageRange, PackageRange>> tighten({ + List<String> packagesToUpgrade = const [], + Map<Package, Map<PackageRange, PackageRange>> existingChanges = const {}, + List<PackageId>? packageVersions, + }) { + final result = {...existingChanges}; + + final toTighten = <(Package, PackageRange)>[]; + + for (final package in workspaceRoot.transitiveWorkspace) { + if (packagesToUpgrade.isEmpty) { + for (final range in [ + ...package.dependencies.values, + ...package.devDependencies.values, + ]) { + toTighten.add((package, range)); + } + } else { + for (final packageToUpgrade in packagesToUpgrade) { + final range = package.dependencies[packageToUpgrade] ?? + package.devDependencies[packageToUpgrade]; + if (range != null) { + toTighten.add((package, range)); + } + } + } + } + + for (final (package, range) in toTighten) { + final changesForPackage = result[package] ??= {}; + final constraint = (changesForPackage[range] ?? range).constraint; + final resolvedVersion = + (packageVersions?.firstWhere((p) => p.name == range.name) ?? + lockFile.packages[range.name])! + .version; + if (range.source is HostedSource && constraint.isAny) { + changesForPackage[range] = range + .toRef() + .withConstraint(VersionConstraint.compatibleWith(resolvedVersion)); + } else if (constraint is VersionRange) { + final min = constraint.min; + if (min != null && min < resolvedVersion) { + changesForPackage[range] = range.toRef().withConstraint( + VersionRange( + min: resolvedVersion, + max: constraint.max, + includeMin: true, + includeMax: constraint.includeMax, + ).asCompatibleWithIfPossible(), + ); + } + } + } + return result; + } + + /// Unless [dryRun], loads `pubspec.yaml` of each [package] in [changeSet] and applies the + /// changes to its (dev)-dependencies using yaml_edit to preserve textual structure. + /// + /// Outputs a summary of changes done or would have been done if not [dryRun]. + void applyChanges(ChangeSet changeSet, bool dryRun) { + if (!dryRun) { + for (final package in workspaceRoot.transitiveWorkspace) { + final changesForPackage = changeSet[package]; + if (changesForPackage == null || changesForPackage.isEmpty) { + continue; + } + final yamlEditor = YamlEditor(readTextFile(package.pubspecPath)); + final deps = package.dependencies.keys; + + for (final change in changesForPackage.values) { + final section = + deps.contains(change.name) ? 'dependencies' : 'dev_dependencies'; + yamlEditor.update( + [section, change.name], + pubspecDescription(change, cache, package), + ); + } + writeTextFile(package.pubspecPath, yamlEditor.toString()); + } + } + _outputChangeSummary(changeSet, dryRun: dryRun); + } + + /// Outputs a summary of [changeSet]. + void _outputChangeSummary( + ChangeSet changeSet, { + required bool dryRun, + }) { + if (workspaceRoot.workspaceChildren.isEmpty) { + final changesToWorkspaceRoot = changeSet[workspaceRoot] ?? {}; + if (changesToWorkspaceRoot.isEmpty) { + final wouldBe = dryRun ? 'would be made to' : 'to'; + log.message('\nNo changes $wouldBe pubspec.yaml!'); + } else { + final changed = dryRun ? 'Would change' : 'Changed'; + log.message('\n$changed ${changesToWorkspaceRoot.length} ' + '${pluralize('constraint', changesToWorkspaceRoot.length)} in pubspec.yaml:'); + changesToWorkspaceRoot.forEach((from, to) { + log.message(' ${from.name}: ${from.constraint} -> ${to.constraint}'); + }); + } + } else { + if (changeSet.isEmpty) { + final wouldBe = dryRun ? 'would be made to' : 'to'; + log.message('\nNo changes $wouldBe any pubspec.yaml!'); + } + for (final package in workspaceRoot.transitiveWorkspace) { + final changesToPackage = changeSet[package] ?? {}; + if (changesToPackage.isEmpty) continue; + final changed = dryRun ? 'Would change' : 'Changed'; + log.message('\n$changed ${changesToPackage.length} ' + '${pluralize('constraint', changesToPackage.length)} in ${package.pubspecPath}:'); + changesToPackage.forEach((from, to) { + log.message(' ${from.name}: ${from.constraint} -> ${to.constraint}'); + }); + } + } + } } + +/// For each package in a workspace, a set of changes to dependencies. +typedef ChangeSet = Map<Package, Map<PackageRange, PackageRange>>;
diff --git a/test/downgrade/tighten_test.dart b/test/downgrade/tighten_test.dart new file mode 100644 index 0000000..7505b83 --- /dev/null +++ b/test/downgrade/tighten_test.dart
@@ -0,0 +1,33 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. 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:test/test.dart'; + +import '../descriptor.dart' as d; +import '../test_pub.dart'; + +void main() { + test('--tighten will set lower bounds to the actually achieved version', + () async { + await servePackages() + ..serve( + 'foo', + '1.0.0', + ) // Because of the bar constraint, this is not achievable. + ..serve('foo', '2.0.0') + ..serve('foo', '3.0.0') + ..serve('bar', '1.0.0', deps: {'foo': '>=2.0.0'}); + + await d.appDir(dependencies: {'foo': '>=1.0.0', 'bar': '^1.0.0'}).create(); + + await pubGet(output: contains('foo 3.0.0')); + await pubDowngrade( + args: ['--tighten'], + output: allOf( + contains('< foo 2.0.0 (was 3.0.0)'), + contains('foo: >=1.0.0 -> >=2.0.0'), + ), + ); + }); +}
diff --git a/test/testdata/goldens/help_test/pub downgrade --help.txt b/test/testdata/goldens/help_test/pub downgrade --help.txt index 483e764..a971395 100644 --- a/test/testdata/goldens/help_test/pub downgrade --help.txt +++ b/test/testdata/goldens/help_test/pub downgrade --help.txt
@@ -11,6 +11,7 @@ --[no-]offline Use cached packages instead of accessing the network. -n, --dry-run Report what dependencies would change but don't change any. -C, --directory=<dir> Run this in the directory <dir>. + --tighten Updates lower bounds in pubspec.yaml to match the resolved version. Run "pub help" to see global options. See https://dart.dev/tools/pub/cmd/pub-downgrade for detailed documentation.
diff --git a/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt b/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt index b5ea61a..0fb9eb7 100644 --- a/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt +++ b/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt
@@ -2,13 +2,13 @@ ## Section 0 $ pub upgrade --major-versions --example + +Changed 1 constraint in pubspec.yaml: + bar: ^1.0.0 -> ^2.0.0 Resolving dependencies... Downloading packages... + bar 2.0.0 Changed 1 dependency! - -Changed 1 constraint in pubspec.yaml: - bar: ^1.0.0 -> ^2.0.0 Resolving dependencies in `./example`... Downloading packages... Got dependencies in `./example`. @@ -18,11 +18,11 @@ ## Section 1 $ pub upgrade --major-versions --directory example + +Changed 1 constraint in pubspec.yaml: + foo: ^1.0.0 -> ^2.0.0 Resolving dependencies in `example`... Downloading packages... > foo 2.0.0 (was 1.0.0) Changed 1 dependency in `example`! -Changed 1 constraint in pubspec.yaml: - foo: ^1.0.0 -> ^2.0.0 -