diff --git a/lib/src/command/upgrade.dart b/lib/src/command/upgrade.dart index 0f15430c3..12ad88907 100644 --- a/lib/src/command/upgrade.dart +++ b/lib/src/command/upgrade.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; import 'package:yaml_edit/yaml_edit.dart'; @@ -18,6 +19,7 @@ import '../package.dart'; import '../package_name.dart'; import '../pubspec.dart'; import '../pubspec_utils.dart'; +import '../sdk.dart'; import '../solver.dart'; import '../source/hosted.dart'; import '../utils.dart'; @@ -65,6 +67,13 @@ class UpgradeCommand extends PubCommand { argParser.addFlag('packages-dir', hide: true); + argParser.addFlag( + 'tighten', + help: + 'Updates lower bounds in pubspec.yaml to match the resolved version.', + negatable: false, + ); + argParser.addFlag( 'major-versions', help: 'Upgrades packages to their latest resolvable versions, ' @@ -92,8 +101,16 @@ class UpgradeCommand extends PubCommand { bool get _dryRun => argResults.flag('dry-run'); + bool get _tighten => argResults.flag('tighten'); + bool get _precompile => argResults.flag('precompile'); + /// List of package names to upgrade, if empty then upgrade all packages. + /// + /// This allows the user to specify list of names that they want the + /// upgrade command to affect. + List get _packagesToUpgrade => argResults.rest; + bool get _upgradeNullSafety => argResults.flag('nullsafety') || argResults.flag('null-safety'); @@ -122,6 +139,20 @@ Consider using the Dart 2.19 sdk to migrate to null safety.'''); await _runUpgradeMajorVersions(); } else { await _runUpgrade(entrypoint); + if (_tighten) { + final changes = tighten( + entrypoint.root.pubspec, + entrypoint.lockFile.packages.values.toList(), + ); + if (!_dryRun) { + final newPubspecText = _updatePubspec(changes); + + if (changes.isNotEmpty) { + writeTextFile(entrypoint.pubspecPath, newPubspecText); + } + } + _outputChangeSummary(changes); + } } if (argResults.flag('example') && entrypoint.example != null) { // Reload the entrypoint to ensure we pick up potential changes that has @@ -134,15 +165,71 @@ Consider using the Dart 2.19 sdk to migrate to null safety.'''); Future _runUpgrade(Entrypoint e, {bool onlySummary = false}) async { await e.acquireDependencies( SolveType.upgrade, - unlock: argResults.rest, + unlock: _packagesToUpgrade, dryRun: _dryRun, precompile: _precompile, summaryOnly: onlySummary, analytics: analytics, ); + _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 tighten( + Pubspec pubspec, + List packages, { + Map existingChanges = const {}, + }) { + final result = {...existingChanges}; + if (argResults.flag('example') && entrypoint.example != null) { + log.warning( + 'Running `upgrade --tighten` only in `${entrypoint.rootDir}`. Run `$topLevelProgram pub upgrade --tighten --directory example/` separately.', + ); + } + final toTighten = _packagesToUpgrade.isEmpty + ? [ + ...pubspec.dependencies.values, + ...pubspec.devDependencies.values, + ] + : [ + for (final name in _packagesToUpgrade) + pubspec.dependencies[name] ?? pubspec.devDependencies[name], + ].whereNotNull(); + for (final range in toTighten) { + final constraint = (result[range] ?? range).constraint; + final resolvedVersion = + packages.firstWhere((p) => p.name == range.name).version; + if (range.source is HostedSource && constraint.isAny) { + result[range] = range + .toRef() + .withConstraint(VersionConstraint.compatibleWith(resolvedVersion)); + } else if (constraint is VersionRange) { + final min = constraint.min; + if (min != null && min < resolvedVersion) { + result[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. /// @@ -154,7 +241,8 @@ Consider using the Dart 2.19 sdk to migrate to null safety.'''); ...entrypoint.root.pubspec.dependencies.keys, ...entrypoint.root.pubspec.devDependencies.keys, ]; - final toUpgrade = argResults.rest.isEmpty ? directDeps : argResults.rest; + final toUpgrade = + _packagesToUpgrade.isEmpty ? directDeps : _packagesToUpgrade; // Check that all package names in upgradeOnly are direct-dependencies final notInDeps = toUpgrade.where((n) => !directDeps.contains(n)); @@ -203,7 +291,7 @@ be direct 'dependencies' or 'dev_dependencies', following packages are not: // Changes to be made to `pubspec.yaml`. // Mapping from original to changed value. - final changes = {}; + var changes = {}; final declaredHostedDependencies = [ ...entrypoint.root.pubspec.dependencies.values, ...entrypoint.root.pubspec.devDependencies.values, @@ -233,7 +321,24 @@ be direct 'dependencies' or 'dev_dependencies', following packages are not: ), ); } - final newPubspecText = _updatePubspec(changes); + var newPubspecText = _updatePubspec(changes); + if (_tighten) { + // Do another solve with the updated constraints to obtain the correct + // versions to tighten to. This should be fast (everything is cached, and + // no backtracking needed) so we don't show a spinner. + + final solveResult = await resolveVersions( + SolveType.upgrade, + cache, + Package.inMemory(_updatedPubspec(newPubspecText, entrypoint)), + ); + changes = tighten( + entrypoint.root.pubspec, + solveResult.packages, + existingChanges: changes, + ); + newPubspecText = _updatePubspec(changes); + } // When doing '--majorVersions' for specific packages we try to update other // packages as little as possible to make a focused change (SolveType.get). @@ -241,7 +346,7 @@ be direct 'dependencies' or 'dev_dependencies', following packages are not: // But without a specific package we want to get as many non-major updates // as possible (SolveType.upgrade). final solveType = - argResults.rest.isEmpty ? SolveType.upgrade : SolveType.get; + _packagesToUpgrade.isEmpty ? SolveType.upgrade : SolveType.get; if (!_dryRun) { if (changes.isNotEmpty) { @@ -249,25 +354,8 @@ be direct 'dependencies' or 'dev_dependencies', following packages are not: } } - String? overridesFileContents; - final overridesPath = - p.join(entrypoint.rootDir, Pubspec.pubspecOverridesFilename); - try { - overridesFileContents = readTextFile(overridesPath); - } on IOException { - overridesFileContents = null; - } - await entrypoint - .withPubspec( - Pubspec.parse( - newPubspecText, - cache.sources, - location: Uri.parse(entrypoint.pubspecPath), - overridesFileContents: overridesFileContents, - overridesLocation: Uri.file(overridesPath), - ), - ) + .withPubspec(_updatedPubspec(newPubspecText, entrypoint)) .acquireDependencies( solveType, dryRun: _dryRun, @@ -291,6 +379,24 @@ be direct 'dependencies' or 'dev_dependencies', following packages are not: _showOfflineWarning(); } + Pubspec _updatedPubspec(String contents, Entrypoint entrypoint) { + String? overridesFileContents; + final overridesPath = + p.join(entrypoint.rootDir, Pubspec.pubspecOverridesFilename); + try { + overridesFileContents = readTextFile(overridesPath); + } on IOException { + overridesFileContents = null; + } + return Pubspec.parse( + contents, + cache.sources, + location: Uri.parse(entrypoint.pubspecPath), + overridesFileContents: overridesFileContents, + overridesLocation: Uri.file(overridesPath), + ); + } + /// Updates `pubspec.yaml` with given [changes]. String _updatePubspec( Map changes, @@ -311,9 +417,7 @@ be direct 'dependencies' or 'dev_dependencies', following packages are not: } /// Outputs a summary of changes made to `pubspec.yaml`. - void _outputChangeSummary( - Map changes, - ) { + void _outputChangeSummary(Map changes) { ArgumentError.checkNotNull(changes, 'changes'); if (changes.isEmpty) { diff --git a/test/testdata/goldens/help_test/pub upgrade --help.txt b/test/testdata/goldens/help_test/pub upgrade --help.txt index 32b7eeafc..2721d13ed 100644 --- a/test/testdata/goldens/help_test/pub upgrade --help.txt +++ b/test/testdata/goldens/help_test/pub upgrade --help.txt @@ -9,6 +9,7 @@ Usage: pub upgrade [dependencies...] --[no-]offline Use cached packages instead of accessing the network. -n, --dry-run Report what dependencies would change but don't change any. --[no-]precompile Precompile executables in immediate dependencies. + --tighten Updates lower bounds in pubspec.yaml to match the resolved version. --major-versions Upgrades packages to their latest resolvable versions, and updates pubspec.yaml. -C, --directory= Run this in the directory . diff --git a/test/upgrade/upgrade_tighten_test.dart b/test/upgrade/upgrade_tighten_test.dart new file mode 100644 index 000000000..7f6374556 --- /dev/null +++ b/test/upgrade/upgrade_tighten_test.dart @@ -0,0 +1,172 @@ +// Copyright (c) 2023, 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() { + group('pub upgrade --tighten', () { + test('updates dependency constraints lower bounds and shows summary report', + () async { + final server = await servePackages(); + + server.serve('foo', '1.0.0'); + server.serve('bar', '0.2.0'); + server.serve('baz', '0.2.0'); + server.serve('boo', '1.0.0'); + + await d.dir('boom', [d.libPubspec('boom', '1.0.0')]).create(); + await d.dir('boom2', [d.libPubspec('boom2', '1.5.0')]).create(); + + await d.appDir( + dependencies: { + 'foo': '^1.0.0', + 'bar': '>=0.1.2 <3.0.0', + 'baz': '0.2.0', + 'boo': 'any', + 'boom': {'path': '../boom'}, + 'boom2': {'path': '../boom2', 'version': '^1.0.0'}, + }, + ).create(); + + await pubGet(); + + server.serve('foo', '1.5.0'); + server.serve('bar', '1.5.0'); + + await pubUpgrade( + args: ['--tighten', '--dry-run'], + output: allOf([ + contains('Would change 4 constraints in pubspec.yaml:'), + contains('foo: ^1.0.0 -> ^1.5.0'), + contains('bar: >=0.1.2 <3.0.0 -> >=1.5.0 <3.0.0'), + contains('boo: any -> ^1.0.0'), + contains('boom2: ^1.0.0 -> ^1.5.0'), + ]), + ); + + await pubUpgrade( + args: ['--tighten'], + output: allOf([ + contains('Changed 4 constraints in pubspec.yaml:'), + contains('foo: ^1.0.0 -> ^1.5.0'), + contains('bar: >=0.1.2 <3.0.0 -> >=1.5.0 <3.0.0'), + contains('boo: any -> ^1.0.0'), + contains('boom2: ^1.0.0 -> ^1.5.0'), + ]), + ); + + await d.appDir( + dependencies: { + 'foo': '^1.5.0', + 'bar': '>=1.5.0 <3.0.0', + 'baz': '0.2.0', + 'boo': '^1.0.0', + 'boom': {'path': '../boom'}, + 'boom2': {'path': '../boom2', 'version': '^1.5.0'}, + }, + ).validate(); + await d.appPackageConfigFile([ + d.packageConfigEntry(name: 'foo', version: '1.5.0'), + d.packageConfigEntry(name: 'bar', version: '1.5.0'), + d.packageConfigEntry(name: 'baz', version: '0.2.0'), + d.packageConfigEntry(name: 'boo', version: '1.0.0'), + d.packageConfigEntry(name: 'boom', path: '../boom'), + d.packageConfigEntry(name: 'boom2', path: '../boom2'), + ]).validate(); + }); + + test( + '--major-versions updates dependency constraints lower bounds and shows summary report', + () async { + final server = await servePackages(); + + server.serve('foo', '1.0.0'); + server.serve('bar', '1.0.0'); + + await d.appDir( + dependencies: { + 'foo': '^1.0.0', + 'bar': '^1.0.0', + }, + ).create(); + + await pubGet(); + + server.serve('foo', '2.0.0'); + server.serve('bar', '1.5.0'); + + await pubUpgrade( + args: ['--tighten', '--major-versions'], + output: allOf([ + contains('Changed 2 constraints in pubspec.yaml:'), + contains('foo: ^1.0.0 -> ^2.0.0'), + contains('bar: ^1.0.0 -> ^1.5.0'), + ]), + ); + + await d.appDir( + dependencies: { + 'foo': '^2.0.0', + 'bar': '^1.5.0', + }, + ).validate(); + await d.appPackageConfigFile([ + d.packageConfigEntry(name: 'foo', version: '2.0.0'), + d.packageConfigEntry(name: 'bar', version: '1.5.0'), + ]).validate(); + }); + + test('can tighten a specific package', () async { + final server = await servePackages(); + + server.serve('foo', '1.0.0'); + server.serve('bar', '1.0.0'); + + await d.appDir( + dependencies: { + 'foo': '^1.0.0', + 'bar': '^1.0.0', + }, + ).create(); + + await pubGet(); + + server.serve('foo', '1.5.0'); + server.serve('bar', '1.5.0'); + + await pubUpgrade( + args: ['--tighten', 'foo'], + output: allOf([ + contains('Changed 1 constraint in pubspec.yaml:'), + contains('foo: ^1.0.0 -> ^1.5.0'), + ]), + ); + + await d.appDir( + dependencies: { + 'foo': '^1.5.0', + 'bar': '^1.0.0', + }, + ).validate(); + await d.appPackageConfigFile([ + d.packageConfigEntry(name: 'foo', version: '1.5.0'), + d.packageConfigEntry(name: 'bar', version: '1.0.0'), + ]).validate(); + + server.serve('foo', '2.0.0'); + server.serve('bar', '2.0.0'); + + await pubUpgrade( + args: ['--tighten', 'bar', '--major-versions'], + output: allOf([ + contains('Changed 1 constraint in pubspec.yaml:'), + contains('bar: ^1.0.0 -> ^2.0.0'), + ]), + ); + }); + }); +}