From 70f2dd6eddd5916f70b3d73dd3170a3d2d3a7681 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Thu, 9 Jun 2022 11:43:59 +0200 Subject: [PATCH] Add bump command to bump requirements to the currently installed version, fixes #7273 (#10829) --- doc/03-cli.md | 18 ++ src/Composer/Command/BumpCommand.php | 227 ++++++++++++++++++ src/Composer/Command/CompletionTrait.php | 12 + src/Composer/Command/RequireCommand.php | 22 +- src/Composer/Console/Application.php | 1 + .../Package/Version/VersionBumper.php | 114 +++++++++ .../Composer/Test/Command/BumpCommandTest.php | 136 +++++++++++ tests/Composer/Test/Mock/FactoryMock.php | 2 +- .../Test/Mock/InstallationManagerMock.php | 2 +- .../Package/Version/VersionBumperTest.php | 69 ++++++ tests/Composer/Test/TestCase.php | 44 ++++ 11 files changed, 634 insertions(+), 13 deletions(-) create mode 100644 src/Composer/Command/BumpCommand.php create mode 100644 src/Composer/Package/Version/VersionBumper.php create mode 100644 tests/Composer/Test/Command/BumpCommandTest.php create mode 100644 tests/Composer/Test/Package/Version/VersionBumperTest.php diff --git a/doc/03-cli.md b/doc/03-cli.md index 30d21c673..8aab64bc5 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -322,6 +322,24 @@ uninstalled. * **--apcu-autoloader-prefix:** Use a custom prefix for the APCu autoloader cache. Implicitly enables `--apcu-autoloader`. +## bump + +The `bump` command increases the lower limit of your composer.json requirements +to the currently installed versions. This helps to ensure your dependencies do not +accidentally get downgraded due to some other conflict, and can slightly improve +dependency resolution performance as it limits the amount of package versions +Composer has to look at. + +Running this blindly on libraries is **NOT** recommended as it will narrow down +your allowed dependencies, which may cause dependency hell for your users. +Running it with `--dev-only` on libraries may be fine however as dev requirements +are local to the library and do not affect consumers of the package. + +### Options + +* **--dev-only:** Only bump requirements in "require-dev". +* **--no-dev-only:** Only bump requirements in "require". + ## reinstall The `reinstall` command looks up installed packages by name, diff --git a/src/Composer/Command/BumpCommand.php b/src/Composer/Command/BumpCommand.php new file mode 100644 index 000000000..3abcbc199 --- /dev/null +++ b/src/Composer/Command/BumpCommand.php @@ -0,0 +1,227 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Command; + +use Composer\DependencyResolver\Request; +use Composer\Package\AliasPackage; +use Composer\Package\Locker; +use Composer\Package\Version\VersionBumper; +use Composer\Package\Version\VersionSelector; +use Composer\Util\Filesystem; +use Symfony\Component\Console\Input\InputInterface; +use Composer\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Composer\Factory; +use Composer\Installer; +use Composer\Installer\InstallerEvents; +use Composer\Json\JsonFile; +use Composer\Json\JsonManipulator; +use Composer\Package\Version\VersionParser; +use Composer\Package\Loader\ArrayLoader; +use Composer\Package\BasePackage; +use Composer\Plugin\CommandEvent; +use Composer\Plugin\PluginEvents; +use Composer\Repository\CompositeRepository; +use Composer\Repository\PlatformRepository; +use Composer\IO\IOInterface; +use Composer\Util\Silencer; + +/** + * @author Jordi Boggiano + */ +final class BumpCommand extends BaseCommand +{ + private const ERROR_GENERIC = 1; + private const ERROR_LOCK_OUTDATED = 2; + + use CompletionTrait; + + protected function configure(): void + { + $this + ->setName('bump') + ->setDescription('Increases the lower limit of your composer.json requirements to the currently installed versions.') + ->setDefinition(array( + new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Optional package name(s) to restrict which packages are bumped.', null, $this->suggestRootRequirement()), + new InputOption('dev-only', 'D', InputOption::VALUE_NONE, 'Only bump requirements in "require-dev".'), + new InputOption('no-dev-only', 'R', InputOption::VALUE_NONE, 'Only bump requirements in "require".'), + )) + ->setHelp( + <<bump command increases the lower limit of your composer.json requirements +to the currently installed versions. This helps to ensure your dependencies do not +accidentally get downgraded due to some other conflict, and can slightly improve +dependency resolution performance as it limits the amount of package versions +Composer has to look at. + +Running this blindly on libraries is **NOT** recommended as it will narrow down +your allowed dependencies, which may cause dependency hell for your users. +Running it with --dev-only on libraries may be fine however as dev requirements +are local to the library and do not affect consumers of the package. + +EOT + ) + ; + } + + /** + * @throws \Seld\JsonLint\ParsingException + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + /** @readonly */ + $composerJsonPath = Factory::getComposerFile(); + $io = $this->getIO(); + + if (!Filesystem::isReadable($composerJsonPath)) { + $io->writeError(''.$composerJsonPath.' is not readable.'); + + return self::ERROR_GENERIC; + } + + $composerJson = new JsonFile($composerJsonPath); + $contents = file_get_contents($composerJson->getPath()); + if (false === $contents) { + $io->writeError(''.$composerJsonPath.' is not readable.'); + + return self::ERROR_GENERIC; + } + + // check for writability by writing to the file as is_writable can not be trusted on network-mounts + // see https://github.com/composer/composer/issues/8231 and https://bugs.php.net/bug.php?id=68926 + if (!is_writable($composerJsonPath) && false === Silencer::call('file_put_contents', $composerJsonPath, $contents)) { + $io->writeError(''.$composerJsonPath.' is not writable.'); + + return self::ERROR_GENERIC; + } + unset($contents); + + $composer = $this->requireComposer(); + if ($composer->getLocker()->isLocked()) { + if (!$composer->getLocker()->isFresh()) { + $io->writeError('The lock file is not up to date with the latest changes in composer.json. Run the appropriate `update` to fix that before you use the `bump` command.'); + + return self::ERROR_LOCK_OUTDATED; + } + + $repo = $composer->getLocker()->getLockedRepository(true); + } else { + $repo = $composer->getRepositoryManager()->getLocalRepository(); + } + + if ($composer->getPackage()->getType() !== 'project' && !$input->getOption('dev-only')) { + $io->writeError('Warning: Bumping dependency constraints is not recommended for libraries as it will narrow down your dependencies and may cause problems for your users.'); + + $contents = $composerJson->read(); + if (!isset($contents['type'])) { + $io->writeError('If your package is not a library, you can explicitly specify the "type" by using "composer config type project".'); + $io->writeError('Alternatively you can use --dev to only bump dependencies within "require-dev".'); + } + unset($contents); + } + + $bumper = new VersionBumper(); + $tasks = []; + if (!$input->getOption('no-dev-only')) { + $tasks['require-dev'] = $composer->getPackage()->getDevRequires(); + }; + if (!$input->getOption('dev-only')) { + $tasks['require'] = $composer->getPackage()->getRequires(); + } + + $updates = []; + foreach ($tasks as $key => $reqs) { + foreach ($reqs as $pkgName => $link) { + if (PlatformRepository::isPlatformPackage($pkgName)) { + continue; + } + $currentConstraint = $link->getPrettyConstraint(); + + $package = $repo->findPackage($pkgName, '*'); + // name must be provided or replaced + if (null === $package) { + continue; + } + while ($package instanceof AliasPackage) { + $package = $package->getAliasOf(); + } + + $bumped = $bumper->bumpRequirement($link->getConstraint(), $package); + + if ($bumped === $currentConstraint) { + continue; + } + + $updates[$key][$pkgName] = $bumped; + } + } + + if (!$this->updateFileCleanly($composerJson, $updates)) { + $composerDefinition = $composerJson->read(); + foreach ($updates as $key => $packages) { + foreach ($packages as $package => $version) { + $composerDefinition[$key][$package] = $version; + } + } + $composerJson->write($composerDefinition); + } + + $changeCount = array_sum(array_map('count', $updates)); + if ($changeCount > 0) { + $io->write(''.$composerJsonPath.' has been updated ('.$changeCount.' changes).'); + } else { + $io->write('No requirements to update in '.$composerJsonPath.'.'); + } + + if ($composer->getLocker()->isLocked() && $changeCount > 0) { + $contents = file_get_contents($composerJson->getPath()); + if (false === $contents) { + throw new \RuntimeException('Unable to read '.$composerJson->getPath().' contents to update the lock file hash.'); + } + $lock = new JsonFile(Factory::getLockFile($composerJsonPath)); + $lockData = $lock->read(); + $lockData['content-hash'] = Locker::getContentHash($contents); + $lock->write($lockData); + } + + return 0; + } + + /** + * @param array<'require'|'require-dev', array> $updates + */ + private function updateFileCleanly(JsonFile $json, array $updates): bool + { + $contents = file_get_contents($json->getPath()); + if (false === $contents) { + throw new \RuntimeException('Unable to read '.$json->getPath().' contents.'); + } + + $manipulator = new JsonManipulator($contents); + + foreach ($updates as $key => $packages) { + foreach ($packages as $package => $version) { + if (!$manipulator->addLink($key, $package, $version)) { + return false; + } + } + } + + if (false === file_put_contents($json->getPath(), $manipulator->getContents())) { + throw new \RuntimeException('Unable to write new '.$json->getPath().' contents.'); + } + + return true; + } +} diff --git a/src/Composer/Command/CompletionTrait.php b/src/Composer/Command/CompletionTrait.php index 5134aac3a..ffd414b39 100644 --- a/src/Composer/Command/CompletionTrait.php +++ b/src/Composer/Command/CompletionTrait.php @@ -45,6 +45,18 @@ trait CompletionTrait return ['dist', 'source', 'auto']; } + /** + * Suggest package names from root requirements. + */ + private function suggestRootRequirement(): \Closure + { + return function (CompletionInput $input): array { + $composer = $this->requireComposer(); + + return array_merge(array_keys($composer->getPackage()->getRequires()), array_keys($composer->getPackage()->getDevRequires())); + }; + } + /** * Suggest package names from installed. */ diff --git a/src/Composer/Command/RequireCommand.php b/src/Composer/Command/RequireCommand.php index 3feb98e6c..9c69ba20f 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -116,13 +116,6 @@ EOT */ protected function execute(InputInterface $input, OutputInterface $output) { - if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { - pcntl_async_signals(true); - pcntl_signal(SIGINT, function () { $this->revertComposerFile(); }); - pcntl_signal(SIGTERM, function () { $this->revertComposerFile(); }); - pcntl_signal(SIGHUP, function () { $this->revertComposerFile(); }); - } - $this->file = Factory::getComposerFile(); $io = $this->getIO(); @@ -151,9 +144,16 @@ EOT $this->composerBackup = file_get_contents($this->json->getPath()); $this->lockBackup = file_exists($this->lock) ? file_get_contents($this->lock) : null; + if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, function () { $this->revertComposerFile(); }); + pcntl_signal(SIGTERM, function () { $this->revertComposerFile(); }); + pcntl_signal(SIGHUP, function () { $this->revertComposerFile(); }); + } + // check for writability by writing to the file as is_writable can not be trusted on network-mounts // see https://github.com/composer/composer/issues/8231 and https://bugs.php.net/bug.php?id=68926 - if (!is_writable($this->file) && !Silencer::call('file_put_contents', $this->file, $this->composerBackup)) { + if (!is_writable($this->file) && false === Silencer::call('file_put_contents', $this->file, $this->composerBackup)) { $io->writeError(''.$this->file.' is not writable.'); return 1; @@ -168,10 +168,10 @@ EOT * @see https://github.com/composer/composer/pull/8313#issuecomment-532637955 */ if ($packageType !== 'project') { - $io->writeError('"--fixed" option is allowed for "project" package types only to prevent possible misuses.'); + $io->writeError('The "--fixed" option is only allowed for packages with a "project" type to prevent possible misuses.'); - if (empty($config['type'])) { - $io->writeError('If your package is not library, you should explicitly specify "type" parameter in composer.json.'); + if (!isset($config['type'])) { + $io->writeError('If your package is not a library, you can explicitly specify the "type" by using "composer config type project".'); } return 1; diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index 26b306675..c5fc2b0d3 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -547,6 +547,7 @@ class Application extends BaseApplication new Command\CheckPlatformReqsCommand(), new Command\FundCommand(), new Command\ReinstallCommand(), + new Command\BumpCommand(), )); if (strpos(__FILE__, 'phar:') === 0 || '1' === Platform::getEnv('COMPOSER_TESTS_ARE_RUNNING')) { diff --git a/src/Composer/Package/Version/VersionBumper.php b/src/Composer/Package/Version/VersionBumper.php new file mode 100644 index 000000000..c272d4da0 --- /dev/null +++ b/src/Composer/Package/Version/VersionBumper.php @@ -0,0 +1,114 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Version; + +use Composer\Filter\PlatformRequirementFilter\IgnoreAllPlatformRequirementFilter; +use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterFactory; +use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface; +use Composer\Package\BasePackage; +use Composer\Package\AliasPackage; +use Composer\Package\PackageInterface; +use Composer\Composer; +use Composer\Package\Loader\ArrayLoader; +use Composer\Package\Dumper\ArrayDumper; +use Composer\Pcre\Preg; +use Composer\Repository\RepositorySet; +use Composer\Repository\PlatformRepository; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Semver\Intervals; +use Composer\Util\Platform; + +/** + * @author Jordi Boggiano + * @internal + */ +class VersionBumper +{ + /** + * Given a constraint, this returns a new constraint with + * the lower bound bumped to match the given package's version. + * + * For example: + * * ^1.0 + 1.2.1 -> ^1.2.1 + * * ^1.2 + 1.2.0 -> ^1.2 + * * ^1.2 || ^2.3 + 1.3.0 -> ^1.3 || ^2.3 + * * ^1.2 || ^2.3 + 2.4.0 -> ^1.2 || ^2.4 + * * ^3@dev + 3.2.99999-dev -> ^3.2@dev + * * ~2 + 2.0-beta.1 -> ~2 + * * dev-master + dev-master -> dev-master + */ + public function bumpRequirement(ConstraintInterface $constraint, PackageInterface $package): string + { + $parser = new VersionParser(); + $prettyConstraint = $constraint->getPrettyString(); + if (str_starts_with($constraint->getPrettyString(), 'dev-')) { + return $prettyConstraint; + } + + $version = $package->getVersion(); + if (str_starts_with($package->getVersion(), 'dev-')) { + $loader = new ArrayLoader($parser); + $dumper = new ArrayDumper(); + $extra = $loader->getBranchAlias($dumper->dump($package)); + + // dev packages without branch alias cannot be processed + if (null === $extra || $extra === VersionParser::DEFAULT_BRANCH_ALIAS) { + return $prettyConstraint; + } + + $version = $extra; + } + + $intervals = Intervals::get($constraint); + + // complex constraints with branch names are not bumped + if (\count($intervals['branches']['names']) > 0) { + return $prettyConstraint; + } + + $major = Preg::replace('{^(\d+).*}', '$1', $version); + $newPrettyConstraint = '^'.Preg::replace('{(?:\.(?:0|9999999))+(-dev)?$}', '', $version); + + // not a simple stable version, abort + if (!Preg::isMatch('{^\^\d+(\.\d+)*$}', $newPrettyConstraint)) { + return $prettyConstraint; + } + + $pattern = '{ + (?<=,|\ |\||^) # leading separator + (?P + \^'.$major.'(?:\.\d+)* # e.g. ^2.anything + | ~'.$major.'(?:\.\d+)? # e.g. ~2 or ~2.2 but no more + | '.$major.'(?:\.[*x])+ # e.g. 2.* or 2.*.* or 2.x.x.x etc + ) + (?=,|$|\ |\||@) # trailing separator + }x'; + if (Preg::isMatchAllWithOffsets($pattern, $prettyConstraint, $matches)) { + $modified = $prettyConstraint; + foreach (array_reverse($matches['constraint']) as $match) { + $modified = substr_replace($modified, $newPrettyConstraint, $match[1], Platform::strlen($match[0])); + } + + // if it is strictly equal to the previous one then no need to change anything + $newConstraint = $parser->parseConstraints($modified); + if (Intervals::isSubsetOf($newConstraint, $constraint) && Intervals::isSubsetOf($constraint, $newConstraint)) { + return $prettyConstraint; + } + + return $modified; + } + + return $prettyConstraint; + } +} diff --git a/tests/Composer/Test/Command/BumpCommandTest.php b/tests/Composer/Test/Command/BumpCommandTest.php new file mode 100644 index 000000000..30770c9f5 --- /dev/null +++ b/tests/Composer/Test/Command/BumpCommandTest.php @@ -0,0 +1,136 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Command; + +use Composer\Json\JsonFile; +use Composer\Test\TestCase; + +class BumpCommandTest extends TestCase +{ + /** + * @dataProvider provideTests + * @param array $composerJson + * @param array $command + * @param array $expected + */ + public function testBump(array $composerJson, array $command, array $expected, bool $lock = true): void + { + $this->initTempComposer($composerJson); + + $packages = [ + $this->getPackage('first/pkg', '2.3.4'), + $this->getPackage('second/pkg', '3.4.0'), + ]; + $devPackages = [ + $this->getPackage('dev/pkg', '2.3.4.5'), + ]; + + $this->createInstalledJson($packages, $devPackages); + if ($lock) { + $this->createComposerLock($packages, $devPackages); + } + + $appTester = $this->getApplicationTester(); + $appTester->run(array_merge(['command' => 'bump'], $command)); + + $json = new JsonFile('./composer.json'); + $this->assertSame($expected, $json->read()); + } + + public function provideTests(): \Generator + { + yield 'bump all by default' => [ + [ + 'require' => [ + 'first/pkg' => '^2.0', + 'second/pkg' => '3.*', + ], + 'require-dev' => [ + 'dev/pkg' => '~2.0', + ], + ], + [], + [ + 'require' => [ + 'first/pkg' => '^2.3.4', + 'second/pkg' => '^3.4', + ], + 'require-dev' => [ + 'dev/pkg' => '^2.3.4.5', + ], + ] + ]; + + yield 'bump only dev with --dev-only' => [ + [ + 'require' => [ + 'first/pkg' => '^2.0', + 'second/pkg' => '3.*', + ], + 'require-dev' => [ + 'dev/pkg' => '~2.0', + ], + ], + ['--dev-only' => true], + [ + 'require' => [ + 'first/pkg' => '^2.0', + 'second/pkg' => '3.*', + ], + 'require-dev' => [ + 'dev/pkg' => '^2.3.4.5', + ], + ] + ]; + + yield 'bump only non-dev with --no-dev-only' => [ + [ + 'require' => [ + 'first/pkg' => '^2.0', + 'second/pkg' => '3.*', + ], + 'require-dev' => [ + 'dev/pkg' => '~2.0', + ], + ], + ['--no-dev-only' => true], + [ + 'require' => [ + 'first/pkg' => '^2.3.4', + 'second/pkg' => '^3.4', + ], + 'require-dev' => [ + 'dev/pkg' => '~2.0', + ], + ] + ]; + + yield 'bump works from installed repo without lock file' => [ + [ + 'require' => [ + 'first/pkg' => '^2.0', + 'second/pkg' => '3.*', + ], + ], + [], + [ + 'require' => [ + 'first/pkg' => '^2.3.4', + 'second/pkg' => '^3.4', + ], + ], + false + ]; + + } +} diff --git a/tests/Composer/Test/Mock/FactoryMock.php b/tests/Composer/Test/Mock/FactoryMock.php index 6c75c3e6b..a4cbe3f77 100644 --- a/tests/Composer/Test/Mock/FactoryMock.php +++ b/tests/Composer/Test/Mock/FactoryMock.php @@ -53,7 +53,7 @@ class FactoryMock extends Factory $rm->setLocalRepository(new InstalledArrayRepository); } - public function createInstallationManager(Loop $loop, IOInterface $io, EventDispatcher $dispatcher = null): InstallationManager + public function createInstallationManager(Loop $loop = null, IOInterface $io = null, EventDispatcher $dispatcher = null): InstallationManager { return new InstallationManagerMock(); } diff --git a/tests/Composer/Test/Mock/InstallationManagerMock.php b/tests/Composer/Test/Mock/InstallationManagerMock.php index 593093ded..31062f736 100644 --- a/tests/Composer/Test/Mock/InstallationManagerMock.php +++ b/tests/Composer/Test/Mock/InstallationManagerMock.php @@ -57,7 +57,7 @@ class InstallationManagerMock extends InstallationManager public function getInstallPath(PackageInterface $package): string { - return ''; + return 'vendor/'.$package->getName(); } public function isPackageInstalled(InstalledRepositoryInterface $repo, PackageInterface $package): bool diff --git a/tests/Composer/Test/Package/Version/VersionBumperTest.php b/tests/Composer/Test/Package/Version/VersionBumperTest.php new file mode 100644 index 000000000..648ffa314 --- /dev/null +++ b/tests/Composer/Test/Package/Version/VersionBumperTest.php @@ -0,0 +1,69 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Package\Version; + +use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterFactory; +use Composer\Package\Version\VersionBumper; +use Composer\Package\Version\VersionSelector; +use Composer\Package\Package; +use Composer\Package\Link; +use Composer\Package\AliasPackage; +use Composer\Repository\PlatformRepository; +use Composer\Package\Version\VersionParser; +use Composer\Test\TestCase; +use Generator; + +class VersionBumperTest extends TestCase +{ + /** + * @dataProvider provideBumpRequirementTests + */ + public function testBumpRequirement(string $requirement, string $prettyVersion, string $expectedRequirement, ?string $branchAlias = null): void + { + $versionBumper = new VersionBumper(); + $versionParser = new VersionParser(); + + $package = new Package('foo/bar', $versionParser->normalize($prettyVersion), $prettyVersion); + + if ($branchAlias !== null) { + $package->setExtra(array('branch-alias' => array($prettyVersion => $branchAlias))); + } + + $newConstraint = $versionBumper->bumpRequirement($versionParser->parseConstraints($requirement), $package); + + // assert that the recommended version is what we expect + $this->assertSame($expectedRequirement, $newConstraint); + } + + public function provideBumpRequirementTests(): Generator + { + // constraint, version, expected recommendation, [branch-alias] + yield 'upgrade caret' => ['^1.0', '1.2.1', '^1.2.1']; + yield 'skip trailing .0s' => ['^1.0', '1.0.0', '^1.0']; + yield 'skip trailing .0s/2' => ['^1.2', '1.2.0', '^1.2']; + yield 'preserve multi constraints' => ['^1.2 || ^2.3', '1.3.2', '^1.3.2 || ^2.3']; + yield 'preserve multi constraints/2' => ['^1.2 || ^2.3', '2.4.0', '^1.2 || ^2.4']; + yield 'preserve multi constraints/3' => ['^1.2 || ^2.3 || ^2', '2.4.0', '^1.2 || ^2.4 || ^2.4']; + yield '@dev is preserved' => ['^3@dev', '3.2.x-dev', '^3.2@dev']; + yield 'non-stable versions abort upgrades' => ['~2', '2.1-beta.1', '~2']; + yield 'dev reqs are skipped' => ['dev-main', 'dev-foo', 'dev-main']; + yield 'dev version does not upgrade' => ['^3.2', 'dev-main', '^3.2']; + yield 'upgrade dev version if aliased' => ['^3.2', 'dev-main', '^3.3', '3.3.x-dev']; + yield 'upgrade major wildcard to caret' => ['2.*', '2.4.0', '^2.4']; + yield 'upgrade major wildcard as x to caret' => ['2.x.x', '2.4.0', '^2.4']; + yield 'leave minor wildcard alone' => ['2.4.*', '2.4.3', '2.4.*']; + yield 'leave patch wildcard alone' => ['2.4.3.*', '2.4.3.2', '2.4.3.*']; + yield 'upgrade tilde to caret when compatible' => ['~2.2', '2.4.3', '^2.4.3']; + yield 'leave patch-only-tilde alone' => ['~2.2.3', '2.2.6', '~2.2.3']; + } +} diff --git a/tests/Composer/Test/TestCase.php b/tests/Composer/Test/TestCase.php index fe7e279bf..fe0f9365b 100644 --- a/tests/Composer/Test/TestCase.php +++ b/tests/Composer/Test/TestCase.php @@ -16,10 +16,13 @@ use Composer\Config; use Composer\Console\Application; use Composer\IO\IOInterface; use Composer\Json\JsonFile; +use Composer\Package\Locker; use Composer\Pcre\Preg; +use Composer\Repository\InstalledFilesystemRepository; use Composer\Semver\VersionParser; use Composer\Package\PackageInterface; use Composer\Semver\Constraint\Constraint; +use Composer\Test\Mock\FactoryMock; use Composer\Test\Mock\HttpDownloaderMock; use Composer\Test\Mock\ProcessExecutorMock; use Composer\Util\Filesystem; @@ -109,6 +112,9 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase * * The directory will be cleaned up on tearDown automatically. * + * @see createInstalledJson + * @see createComposerLock + * @see getApplicationTester * @param mixed[] $composerJson * @param mixed[] $authJson * @return string the newly created temp dir @@ -138,6 +144,44 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase return $dir; } + /** + * Creates a vendor/composer/installed.json in CWD with the given packages + * + * @param PackageInterface[] $packages + * @param PackageInterface[] $devPackages + */ + protected function createInstalledJson(array $packages = [], array $devPackages = [], bool $devMode = true): void + { + mkdir('vendor/composer', 0777, true); + $repo = new InstalledFilesystemRepository(new JsonFile('vendor/composer/installed.json')); + $repo->setDevPackageNames(array_map(function (PackageInterface $pkg) { return $pkg->getPrettyName(); }, $devPackages)); + foreach ($packages as $pkg) { + $repo->addPackage($pkg); + mkdir('vendor/'.$pkg->getName(), 0777, true); + } + foreach ($devPackages as $pkg) { + $repo->addPackage($pkg); + mkdir('vendor/'.$pkg->getName(), 0777, true); + } + + $factory = new FactoryMock(); + $repo->write($devMode, $factory->createInstallationManager()); + } + + /** + * Creates a composer.lock in CWD with the given packages + * + * @param PackageInterface[] $packages + * @param PackageInterface[] $devPackages + */ + protected function createComposerLock(array $packages = [], array $devPackages = []): void + { + $factory = new FactoryMock(); + + $locker = new Locker($this->getMockBuilder(IOInterface::class)->getMock(), new JsonFile('./composer.lock'), $factory->createInstallationManager(), (string) file_get_contents('./composer.json')); + $locker->setLockData($packages, $devPackages, [], [], [], 'dev', [], false, false, []); + } + public function getApplicationTester(): ApplicationTester { $application = new Application();