Skip to content

Commit

Permalink
Add --minimal-changes mode to perform partial updates --with-dependen…
Browse files Browse the repository at this point in the history
…cies while changing only what is necessary in other dependencies (composer#11665)
  • Loading branch information
Seldaek authored and theoboldalex committed Jan 10, 2024
1 parent c97e2d2 commit 44ba0ba
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 9 deletions.
11 changes: 11 additions & 0 deletions doc/03-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/Composer/Command/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ protected function initialize(InputInterface $input, OutputInterface $output)
'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) {
Expand Down
2 changes: 2 additions & 0 deletions src/Composer/Command/RemoveCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ protected function configure()
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).'),
Expand Down Expand Up @@ -286,6 +287,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
->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
Expand Down
2 changes: 2 additions & 0 deletions src/Composer/Command/RequireCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ protected function configure()
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`.'),
Expand Down Expand Up @@ -479,6 +480,7 @@ private function doUpdate(InputInterface $input, OutputInterface $output, IOInte
->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
Expand Down
2 changes: 2 additions & 0 deletions src/Composer/Command/UpdateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ protected function configure()
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.'),
])
Expand Down Expand Up @@ -240,6 +241,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
->setTemporaryConstraints($temporaryConstraints)
->setAudit(!$input->getOption('no-audit'))
->setAuditFormat($this->getAuditFormat($input))
->setMinimalUpdate($input->getOption('minimal-changes'))
;

if ($input->getOption('no-plugins')) {
Expand Down
24 changes: 23 additions & 1 deletion src/Composer/DependencyResolver/DefaultPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,21 @@ class DefaultPolicy implements PolicyInterface
private $preferStable;
/** @var bool */
private $preferLowest;
/** @var array<string, string>|null */
private $preferredVersions;
/** @var array<int, array<string, array<int, int>>> */
private $preferredPackageResultCachePerPool;
/** @var array<int, array<string, int>> */
private $sortingCachePerPool;

public function __construct(bool $preferStable = false, bool $preferLowest = false)
/**
* @param array<string, string>|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;
}

/**
Expand Down Expand Up @@ -204,6 +210,22 @@ protected function replaces(BasePackage $source, BasePackage $target): bool
*/
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]);
Expand Down
41 changes: 34 additions & 7 deletions src/Composer/Installer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -464,7 +466,7 @@ protected function doUpdate(InstalledRepositoryInterface $localRepo, bool $doIns
$this->io->writeError('<info>Loading composer repositories with package information</info>');

// 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) {
Expand Down Expand Up @@ -904,7 +906,7 @@ private function createRepositorySet(bool $forUpdate, PlatformRepository $platfo
return $repositorySet;
}

private function createPolicy(bool $forUpdate): DefaultPolicy
private function createPolicy(bool $forUpdate, ?LockArrayRepository $lockedRepo = null): DefaultPolicy
{
$preferStable = null;
$preferLowest = null;
Expand All @@ -921,7 +923,18 @@ private function createPolicy(bool $forUpdate): DefaultPolicy
$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);
}

/**
Expand Down Expand Up @@ -1384,7 +1397,7 @@ public function setUpdateAllowTransitiveDependencies(int $updateAllowTransitiveD
*/
public function setPreferStable(bool $preferStable = true): self
{
$this->preferStable = (bool) $preferStable;
$this->preferStable = $preferStable;

return $this;
}
Expand All @@ -1396,7 +1409,21 @@ public function setPreferStable(bool $preferStable = true): self
*/
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;
}
Expand All @@ -1410,7 +1437,7 @@ public function setPreferLowest(bool $preferLowest = true): self
*/
public function setWriteLock(bool $writeLock = true): self
{
$this->writeLock = (bool) $writeLock;
$this->writeLock = $writeLock;

return $this;
}
Expand All @@ -1424,7 +1451,7 @@ public function setWriteLock(bool $writeLock = true): self
*/
public function setExecuteOperations(bool $executeOperations = true): self
{
$this->executeOperations = (bool) $executeOperations;
$this->executeOperations = $executeOperations;

return $this;
}
Expand Down
53 changes: 53 additions & 0 deletions tests/Composer/Test/DependencyResolver/DefaultPolicyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,59 @@ public function testSelectNewestWithDevPicksNonDev(): void
$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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion tests/Composer/Test/InstallerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ private function doTestIntegration(string $file, string $message, ?string $condi
$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);
Expand Down Expand Up @@ -412,7 +413,8 @@ private function doTestIntegration(string $file, string $message, ?string $condi
->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();
});
Expand Down

0 comments on commit 44ba0ba

Please sign in to comment.