diff --git a/doc/03-cli.md b/doc/03-cli.md index c99624db3..88db063c3 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -232,6 +232,7 @@ php composer.phar update vendor/package:2.0.1 vendor/package2:3.0.* COMPOSER_PREFER_LOWEST=1 env var. * **--minimal-changes (-m):** 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. +* **--patch-only:** Only allow patch version updates for currently installed dependencies. * **--interactive:** Interactive interface with autocompletion to select the packages to update. * **--root-reqs:** Restricts the update to your first degree dependencies. * **--bump-after-update:** Runs `bump` after performing the update. Set to `dev` or `no-dev` to only bump those dependencies. diff --git a/src/Composer/Command/UpdateCommand.php b/src/Composer/Command/UpdateCommand.php index 756aa00d9..6747242da 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -28,6 +28,7 @@ use Composer\Repository\CompositeRepository; use Composer\Repository\PlatformRepository; use Composer\Repository\RepositoryInterface; use Composer\Repository\RepositorySet; +use Composer\Semver\Constraint\MultiConstraint; use Composer\Semver\Intervals; use Composer\Util\HttpDownloader; use Composer\Advisory\Auditor; @@ -83,6 +84,7 @@ class UpdateCommand extends BaseCommand 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('patch-only', null, InputOption::VALUE_NONE, 'Only allow patch version updates for currently installed dependencies.'), 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.'), new InputOption('bump-after-update', null, InputOption::VALUE_OPTIONAL, 'Runs bump after performing the update.', false, ['dev', 'no-dev', 'all']), @@ -175,6 +177,26 @@ EOT } } + if ($input->getOption('patch-only')) { + if (!$composer->getLocker()->isLocked()) { + throw new \InvalidArgumentException('patch-only can only be used with a lock file present'); + } + foreach ($composer->getLocker()->getLockedRepository(true)->getCanonicalPackages() as $package) { + if ($package->isDev()) { + continue; + } + if (!Preg::isMatch('{^(\d+\.\d+\.\d+)}', $package->getVersion(), $match)) { + continue; + } + $constraint = $parser->parseConstraints('~'.$match[1]); + if (isset($temporaryConstraints[$package->getName()])) { + $temporaryConstraints[$package->getName()] = MultiConstraint::create([$temporaryConstraints[$package->getName()], $constraint], true); + } else { + $temporaryConstraints[$package->getName()] = $constraint; + } + } + } + if ($input->getOption('interactive')) { $packages = $this->getPackagesInteractively($io, $input, $output, $composer, $packages); } diff --git a/tests/Composer/Test/Command/UpdateCommandTest.php b/tests/Composer/Test/Command/UpdateCommandTest.php index dd8427d29..0a5938805 100644 --- a/tests/Composer/Test/Command/UpdateCommandTest.php +++ b/tests/Composer/Test/Command/UpdateCommandTest.php @@ -185,7 +185,69 @@ Your requirements could not be resolved to an installable set of packages. - root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (dep/pkg:^2). OUTPUT ]; + } + public function testUpdateWithPatchOnly(): void + { + $this->initTempComposer([ + 'repositories' => [ + 'packages' => [ + 'type' => 'package', + 'package' => [ + ['name' => 'root/req', 'version' => '1.0.0'], + ['name' => 'root/req', 'version' => '1.0.1'], + ['name' => 'root/req', 'version' => '1.1.0'], + ['name' => 'root/req2', 'version' => '1.0.0'], + ['name' => 'root/req2', 'version' => '1.0.1'], + ['name' => 'root/req2', 'version' => '1.1.0'], + ['name' => 'root/req3', 'version' => '1.0.0'], + ['name' => 'root/req3', 'version' => '1.0.1'], + ['name' => 'root/req3', 'version' => '1.1.0'], + ], + ], + ], + 'require' => [ + 'root/req' => '1.*', + 'root/req2' => '1.*', + 'root/req3' => '1.*', + ], + ]); + + $package = self::getPackage('root/req', '1.0.0'); + $package2 = self::getPackage('root/req2', '1.0.0'); + $package3 = self::getPackage('root/req3', '1.0.0'); + $this->createComposerLock([$package, $package2, $package3]); + + $appTester = $this->getApplicationTester(); + // root/req fails because of incompatible --with requirement + $appTester->run(array_merge(['command' => 'update', '--dry-run' => true, '--no-audit' => true, '--no-install' => true, '--patch-only' => true, '--with' => ['root/req:^1.1']])); + + $expected = <<= 1.1.0.0-dev < 2.0.0.0-dev] [>= 1.0.0.0-dev < 1.1.0.0-dev]]). +OUTPUT; + + self::assertStringMatchesFormat(trim($expected), trim($appTester->getDisplay(true))); + + $appTester = $this->getApplicationTester(); + // root/req upgrades to 1.0.1 as that is compatible with the --with requirement now + // root/req2 upgrades to 1.0.1 only due to --patch-only + // root/req3 does not update as it is not in the allowlist + $appTester->run(array_merge(['command' => 'update', '--dry-run' => true, '--no-audit' => true, '--no-install' => true, '--patch-only' => true, '--with' => ['root/req:^1.0.1'], 'packages' => ['root/req', 'root/req2']])); + + $expected = << 1.0.1) + - Upgrading root/req2 (1.0.0 => 1.0.1) +OUTPUT; + + self::assertStringMatchesFormat(trim($expected), trim($appTester->getDisplay(true))); } public function testInteractiveModeThrowsIfNoPackageToUpdate(): void