From 899dcedf66fcd9b8f6876248ee813e5d82a0de68 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Thu, 26 Oct 2023 10:25:04 +0200 Subject: [PATCH] Add --minimal-changes mode to perform partial updates --with-dependencies while changing only what is necessary in other dependencies (#11665) --- doc/03-cli.md | 11 ++++ src/Composer/Command/BaseCommand.php | 1 + src/Composer/Command/RemoveCommand.php | 2 + src/Composer/Command/RequireCommand.php | 2 + src/Composer/Command/UpdateCommand.php | 2 + .../DependencyResolver/DefaultPolicy.php | 24 ++++++- src/Composer/Installer.php | 41 +++++++++--- .../DependencyResolver/DefaultPolicyTest.php | 53 ++++++++++++++++ .../update-allow-list-minimal-changes.test | 62 +++++++++++++++++++ tests/Composer/Test/InstallerTest.php | 4 +- 10 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 tests/Composer/Test/Fixtures/installer/update-allow-list-minimal-changes.test diff --git a/doc/03-cli.md b/doc/03-cli.md index dd6e7001e..166fed193 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -229,6 +229,8 @@ php composer.phar update vendor/package:2.0.1 vendor/package2:3.0.* * **--prefer-lowest:** Prefer lowest versions of dependencies. Useful for testing minimal versions of requirements, generally used with `--prefer-stable`. Can also be set via the COMPOSER_PREFER_LOWEST=1 env var. +* **--minimal-changes:** During a partial update with `-w`/`-W`, only perform absolutely necessary + changes to transitive dependencies. Can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var. * **--interactive:** Interactive interface with autocompletion to select the packages to update. * **--root-reqs:** Restricts the update to your first degree dependencies. @@ -288,6 +290,8 @@ If you do not specify a package, Composer will prompt you to search for a packag * **--prefer-lowest:** Prefer lowest versions of dependencies. Useful for testing minimal versions of requirements, generally used with `--prefer-stable`. Can also be set via the COMPOSER_PREFER_LOWEST=1 env var. +* **--minimal-changes:** During an update with `-w`/`-W`, only perform absolutely necessary + changes to transitive dependencies. Can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var. * **--sort-packages:** Keep packages sorted in `composer.json`. * **--optimize-autoloader (-o):** Convert PSR-0/4 autoloading to classmap to get a faster autoloader. This is recommended especially for production, but @@ -326,6 +330,8 @@ uninstalled. (Deprecated, is now default behavior) * **--update-with-all-dependencies (-W):** Allows all inherited dependencies to be updated, including those that are root requirements. +* **--minimal-changes:** During an update with `-w`/`-W`, only perform absolutely necessary + changes to transitive dependencies. Can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var. * **--ignore-platform-reqs:** ignore all platform requirements (`php`, `hhvm`, `lib-*` and `ext-*`) and force the installation even if the local machine does not fulfill these. @@ -1290,6 +1296,11 @@ If set to `1`, it is the equivalent of passing the `--prefer-stable` option to If set to `1`, it is the equivalent of passing the `--prefer-lowest` option to `update` or `require`. +### COMPOSER_MINIMAL_CHANGES + +If set to `1`, it is the equivalent of passing the `--minimal-changes` option to +`update`, `require` or `remove`. + ### COMPOSER_IGNORE_PLATFORM_REQ or COMPOSER_IGNORE_PLATFORM_REQS If `COMPOSER_IGNORE_PLATFORM_REQS` set to `1`, it is the equivalent of passing the `--ignore-platform-reqs` argument. diff --git a/src/Composer/Command/BaseCommand.php b/src/Composer/Command/BaseCommand.php index a33f3a5b3..ed318e079 100644 --- a/src/Composer/Command/BaseCommand.php +++ b/src/Composer/Command/BaseCommand.php @@ -248,6 +248,7 @@ abstract class BaseCommand extends Command 'COMPOSER_NO_DEV' => ['no-dev', 'update-no-dev'], 'COMPOSER_PREFER_STABLE' => ['prefer-stable'], 'COMPOSER_PREFER_LOWEST' => ['prefer-lowest'], + 'COMPOSER_MINIMAL_CHANGES' => ['minimal-changes'], ]; foreach ($envOptions as $envName => $optionNames) { foreach ($optionNames as $optionName) { diff --git a/src/Composer/Command/RemoveCommand.php b/src/Composer/Command/RemoveCommand.php index a97896df9..b40fb774e 100644 --- a/src/Composer/Command/RemoveCommand.php +++ b/src/Composer/Command/RemoveCommand.php @@ -59,6 +59,7 @@ class RemoveCommand extends BaseCommand new InputOption('update-with-all-dependencies', 'W', InputOption::VALUE_NONE, 'Allows all inherited dependencies to be updated, including those that are root requirements.'), new InputOption('with-all-dependencies', null, InputOption::VALUE_NONE, 'Alias for --update-with-all-dependencies'), new InputOption('no-update-with-dependencies', null, InputOption::VALUE_NONE, 'Does not allow inherited dependencies to be updated with explicit dependencies.'), + new InputOption('minimal-changes', 'm', InputOption::VALUE_NONE, 'During an update with -w/-W, only perform absolutely necessary changes to transitive dependencies (can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var).'), new InputOption('unused', null, InputOption::VALUE_NONE, 'Remove all packages which are locked but not required by any other package.'), new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages).'), new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'), @@ -286,6 +287,7 @@ EOT ->setDryRun($dryRun) ->setAudit(!$input->getOption('no-audit')) ->setAuditFormat($this->getAuditFormat($input)) + ->setMinimalUpdate($input->getOption('minimal-changes')) ; // if no lock is present, we do not do a partial update as diff --git a/src/Composer/Command/RequireCommand.php b/src/Composer/Command/RequireCommand.php index 51cb69c1d..658d58de3 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -103,6 +103,7 @@ class RequireCommand extends BaseCommand new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'), new InputOption('prefer-stable', null, InputOption::VALUE_NONE, 'Prefer stable versions of dependencies (can also be set via the COMPOSER_PREFER_STABLE=1 env var).'), new InputOption('prefer-lowest', null, InputOption::VALUE_NONE, 'Prefer lowest versions of dependencies (can also be set via the COMPOSER_PREFER_LOWEST=1 env var).'), + new InputOption('minimal-changes', 'm', InputOption::VALUE_NONE, 'During an update with -w/-W, only perform absolutely necessary changes to transitive dependencies (can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var).'), new InputOption('sort-packages', null, InputOption::VALUE_NONE, 'Sorts packages when adding/updating a new dependency'), new InputOption('optimize-autoloader', 'o', InputOption::VALUE_NONE, 'Optimize autoloader during autoloader dump'), new InputOption('classmap-authoritative', 'a', InputOption::VALUE_NONE, 'Autoload classes from the classmap only. Implicitly enables `--optimize-autoloader`.'), @@ -479,6 +480,7 @@ EOT ->setPreferLowest($input->getOption('prefer-lowest')) ->setAudit(!$input->getOption('no-audit')) ->setAuditFormat($this->getAuditFormat($input)) + ->setMinimalUpdate($input->getOption('minimal-changes')) ; // if no lock is present, or the file is brand new, we do not do a diff --git a/src/Composer/Command/UpdateCommand.php b/src/Composer/Command/UpdateCommand.php index b371157e9..621f984c2 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -75,6 +75,7 @@ class UpdateCommand extends BaseCommand new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'), new InputOption('prefer-stable', null, InputOption::VALUE_NONE, 'Prefer stable versions of dependencies (can also be set via the COMPOSER_PREFER_STABLE=1 env var).'), new InputOption('prefer-lowest', null, InputOption::VALUE_NONE, 'Prefer lowest versions of dependencies (can also be set via the COMPOSER_PREFER_LOWEST=1 env var).'), + new InputOption('minimal-changes', 'm', InputOption::VALUE_NONE, 'During a partial update with -w/-W, only perform absolutely necessary changes to transitive dependencies (can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var).'), new InputOption('interactive', 'i', InputOption::VALUE_NONE, 'Interactive interface with autocompletion to select the packages to update.'), new InputOption('root-reqs', null, InputOption::VALUE_NONE, 'Restricts the update to your first degree dependencies.'), ]) @@ -238,6 +239,7 @@ EOT ->setTemporaryConstraints($temporaryConstraints) ->setAudit(!$input->getOption('no-audit')) ->setAuditFormat($this->getAuditFormat($input)) + ->setMinimalUpdate($input->getOption('minimal-changes')) ; if ($input->getOption('no-plugins')) { diff --git a/src/Composer/DependencyResolver/DefaultPolicy.php b/src/Composer/DependencyResolver/DefaultPolicy.php index 8d27a6602..f8176ae72 100644 --- a/src/Composer/DependencyResolver/DefaultPolicy.php +++ b/src/Composer/DependencyResolver/DefaultPolicy.php @@ -28,15 +28,21 @@ class DefaultPolicy implements PolicyInterface private $preferStable; /** @var bool */ private $preferLowest; + /** @var array|null */ + private $preferredVersions; /** @var array>> */ private $preferredPackageResultCachePerPool; /** @var array> */ private $sortingCachePerPool; - public function __construct(bool $preferStable = false, bool $preferLowest = false) + /** + * @param array|null $preferredVersions Must be an array of package name => normalized version + */ + public function __construct(bool $preferStable = false, bool $preferLowest = false, ?array $preferredVersions = null) { $this->preferStable = $preferStable; $this->preferLowest = $preferLowest; + $this->preferredVersions = $preferredVersions; } /** @@ -204,6 +210,22 @@ class DefaultPolicy implements PolicyInterface */ protected function pruneToBestVersion(Pool $pool, array $literals): array { + if ($this->preferredVersions !== null) { + $name = $pool->literalToPackage($literals[0])->getName(); + if (isset($this->preferredVersions[$name])) { + $preferredVersion = $this->preferredVersions[$name]; + $bestLiterals = []; + foreach ($literals as $literal) { + if ($pool->literalToPackage($literal)->getVersion() === $preferredVersion) { + $bestLiterals[] = $literal; + } + } + if (\count($bestLiterals) > 0) { + return $bestLiterals; + } + } + } + $operator = $this->preferLowest ? '<' : '>'; $bestLiterals = [$literals[0]]; $bestPackage = $pool->literalToPackage($literals[0]); diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 9c0054778..0efbcd6f3 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -165,6 +165,8 @@ class Installer /** @var bool */ protected $preferLowest = false; /** @var bool */ + protected $minimalUpdate = false; + /** @var bool */ protected $writeLock; /** @var bool */ protected $executeOperations = true; @@ -464,7 +466,7 @@ class Installer $this->io->writeError('Loading composer repositories with package information'); // creating repository set - $policy = $this->createPolicy(true); + $policy = $this->createPolicy(true, $lockedRepository); $repositorySet = $this->createRepositorySet(true, $platformRepo, $aliases); $repositories = $this->repositoryManager->getRepositories(); foreach ($repositories as $repository) { @@ -904,7 +906,7 @@ class Installer return $repositorySet; } - private function createPolicy(bool $forUpdate): DefaultPolicy + private function createPolicy(bool $forUpdate, ?LockArrayRepository $lockedRepo = null): DefaultPolicy { $preferStable = null; $preferLowest = null; @@ -921,7 +923,18 @@ class Installer $preferLowest = $this->preferLowest; } - return new DefaultPolicy($preferStable, $preferLowest); + $preferredVersions = null; + if ($forUpdate && $this->minimalUpdate && $this->updateAllowList !== null && $lockedRepo !== null) { + $preferredVersions = []; + foreach ($lockedRepo->getPackages() as $pkg) { + if ($pkg instanceof AliasPackage || in_array($pkg->getName(), $this->updateAllowList, true)) { + continue; + } + $preferredVersions[$pkg->getName()] = $pkg->getVersion(); + } + } + + return new DefaultPolicy($preferStable, $preferLowest, $preferredVersions); } /** @@ -1384,7 +1397,7 @@ class Installer */ public function setPreferStable(bool $preferStable = true): self { - $this->preferStable = (bool) $preferStable; + $this->preferStable = $preferStable; return $this; } @@ -1396,7 +1409,21 @@ class Installer */ public function setPreferLowest(bool $preferLowest = true): self { - $this->preferLowest = (bool) $preferLowest; + $this->preferLowest = $preferLowest; + + return $this; + } + + /** + * Only relevant for partial updates (with setUpdateAllowList), if this is enabled currently locked versions will be preferred for packages which are not in the allowlist + * + * This reduces the update to + * + * @return Installer + */ + public function setMinimalUpdate(bool $minimalUpdate = true): self + { + $this->minimalUpdate = $minimalUpdate; return $this; } @@ -1410,7 +1437,7 @@ class Installer */ public function setWriteLock(bool $writeLock = true): self { - $this->writeLock = (bool) $writeLock; + $this->writeLock = $writeLock; return $this; } @@ -1424,7 +1451,7 @@ class Installer */ public function setExecuteOperations(bool $executeOperations = true): self { - $this->executeOperations = (bool) $executeOperations; + $this->executeOperations = $executeOperations; return $this; } diff --git a/tests/Composer/Test/DependencyResolver/DefaultPolicyTest.php b/tests/Composer/Test/DependencyResolver/DefaultPolicyTest.php index 61d5206f2..2f888d768 100644 --- a/tests/Composer/Test/DependencyResolver/DefaultPolicyTest.php +++ b/tests/Composer/Test/DependencyResolver/DefaultPolicyTest.php @@ -121,6 +121,59 @@ class DefaultPolicyTest extends TestCase $this->assertSame($expected, $selected); } + public function testSelectNewestWithPreferredVersionPicksPreferredVersionIfAvailable(): void + { + $this->repo->addPackage($packageA1 = self::getPackage('A', '1.0.0')); + $this->repo->addPackage($packageA2 = self::getPackage('A', '1.1.0')); + $this->repo->addPackage($packageA2b = self::getPackage('A', '1.1.0')); + $this->repo->addPackage($packageA3 = self::getPackage('A', '1.2.0')); + $this->repositorySet->addRepository($this->repo); + + $pool = $this->repositorySet->createPoolForPackage('A', $this->repoLocked); + + $literals = [$packageA1->getId(), $packageA2->getId(), $packageA2b->getId(), $packageA3->getId()]; + $expected = [$packageA2->getId(), $packageA2b->getId()]; + + $policy = new DefaultPolicy(false, false, ['a' => '1.1.0.0']); + $selected = $policy->selectPreferredPackages($pool, $literals); + + $this->assertSame($expected, $selected); + } + + public function testSelectNewestWithPreferredVersionPicksNewestOtherwise(): void + { + $this->repo->addPackage($packageA1 = self::getPackage('A', '1.0.0')); + $this->repo->addPackage($packageA2 = self::getPackage('A', '1.2.0')); + $this->repositorySet->addRepository($this->repo); + + $pool = $this->repositorySet->createPoolForPackage('A', $this->repoLocked); + + $literals = [$packageA1->getId(), $packageA2->getId()]; + $expected = [$packageA2->getId()]; + + $policy = new DefaultPolicy(false, false, ['a' => '1.1.0.0']); + $selected = $policy->selectPreferredPackages($pool, $literals); + + $this->assertSame($expected, $selected); + } + + public function testSelectNewestWithPreferredVersionPicksLowestIfPreferLowest(): void + { + $this->repo->addPackage($packageA1 = self::getPackage('A', '1.0.0')); + $this->repo->addPackage($packageA2 = self::getPackage('A', '1.2.0')); + $this->repositorySet->addRepository($this->repo); + + $pool = $this->repositorySet->createPoolForPackage('A', $this->repoLocked); + + $literals = [$packageA1->getId(), $packageA2->getId()]; + $expected = [$packageA1->getId()]; + + $policy = new DefaultPolicy(false, true, ['a' => '1.1.0.0']); + $selected = $policy->selectPreferredPackages($pool, $literals); + + $this->assertSame($expected, $selected); + } + public function testRepositoryOrderingAffectsPriority(): void { $repo1 = new ArrayRepository; diff --git a/tests/Composer/Test/Fixtures/installer/update-allow-list-minimal-changes.test b/tests/Composer/Test/Fixtures/installer/update-allow-list-minimal-changes.test new file mode 100644 index 000000000..889555ed0 --- /dev/null +++ b/tests/Composer/Test/Fixtures/installer/update-allow-list-minimal-changes.test @@ -0,0 +1,62 @@ +--TEST-- +Updating transitive dependencies only updates what is really required when a minimal update is requested + +* dependency/pkg has to upgrade to 2.x +* dependency/pkg2 remains at 1.0.0 and does not upgrade to 1.1.0 even though it would without minimal update +--COMPOSER-- +{ + "repositories": [ + { + "type": "package", + "package": [ + { "name": "allowed/pkg", "version": "1.1.0", "require": { "dependency/pkg": "2.*", "dependency/pkg2": "1.*" } }, + { "name": "allowed/pkg", "version": "1.0.0", "require": { "dependency/pkg": "1.*", "dependency/pkg2": "1.*" } }, + { "name": "dependency/pkg", "version": "2.0.0" }, + { "name": "dependency/pkg", "version": "1.1.0" }, + { "name": "dependency/pkg", "version": "1.0.0" }, + { "name": "dependency/pkg2", "version": "2.0.0" }, + { "name": "dependency/pkg2", "version": "1.1.0" }, + { "name": "dependency/pkg2", "version": "1.0.0" }, + { "name": "unrelated/pkg", "version": "1.1.0", "require": { "unrelated/pkg-dependency": "1.*" } }, + { "name": "unrelated/pkg", "version": "1.0.0", "require": { "unrelated/pkg-dependency": "1.*" } }, + { "name": "unrelated/pkg-dependency", "version": "1.1.0" }, + { "name": "unrelated/pkg-dependency", "version": "1.0.0" } + ] + } + ], + "require": { + "allowed/pkg": "1.*", + "unrelated/pkg": "1.*" + } +} +--INSTALLED-- +[ + { "name": "allowed/pkg", "version": "1.0.0", "require": { "dependency/pkg": "1.*", "dependency/pkg2": "1.*" } }, + { "name": "dependency/pkg", "version": "1.0.0" }, + { "name": "dependency/pkg2", "version": "1.0.0" }, + { "name": "unrelated/pkg", "version": "1.0.0", "require": { "unrelated/pkg-dependency": "1.*" } }, + { "name": "unrelated/pkg-dependency", "version": "1.0.0" } +] +--LOCK-- +{ + "packages": [ + { "name": "allowed/pkg", "version": "1.0.0", "require": { "dependency/pkg": "1.*", "dependency/pkg2": "1.*" } }, + { "name": "dependency/pkg", "version": "1.0.0" }, + { "name": "dependency/pkg2", "version": "1.0.0" }, + { "name": "unrelated/pkg", "version": "1.0.0", "require": { "unrelated/pkg-dependency": "1.*" } }, + { "name": "unrelated/pkg-dependency", "version": "1.0.0" } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} +--RUN-- +update allowed/pkg --with-all-dependencies --minimal-changes +--EXPECT-- +Upgrading dependency/pkg (1.0.0 => 2.0.0) +Upgrading allowed/pkg (1.0.0 => 1.1.0) diff --git a/tests/Composer/Test/InstallerTest.php b/tests/Composer/Test/InstallerTest.php index eea6f0d73..511d649a8 100644 --- a/tests/Composer/Test/InstallerTest.php +++ b/tests/Composer/Test/InstallerTest.php @@ -381,6 +381,7 @@ class InstallerTest extends TestCase $update->addOption('lock', null, InputOption::VALUE_NONE); $update->addOption('with-all-dependencies', null, InputOption::VALUE_NONE); $update->addOption('with-dependencies', null, InputOption::VALUE_NONE); + $update->addOption('minimal-changes', null, InputOption::VALUE_NONE); $update->addOption('prefer-stable', null, InputOption::VALUE_NONE); $update->addOption('prefer-lowest', null, InputOption::VALUE_NONE); $update->addArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL); @@ -412,7 +413,8 @@ class InstallerTest extends TestCase ->setPreferStable($input->getOption('prefer-stable')) ->setPreferLowest($input->getOption('prefer-lowest')) ->setPlatformRequirementFilter(PlatformRequirementFilterFactory::fromBoolOrList($ignorePlatformReqs)) - ->setAudit(false); + ->setAudit(false) + ->setMinimalUpdate($input->getOption('minimal-changes')); return $installer->run(); });