diff --git a/README.md b/README.md index 967ac7e..7c7c38f 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,16 @@ Example: composer patch-remote-to-local patches ``` +### Patch Migrate + +```sh +composer patch-migrate-config +``` + +The patch migrate command migrates your Composer Patches 1 configuration to the Composer Patches 2 format. It handles the migration of patches, ignored patches, patch levels (converted to package depths). + +After a successful migration, the command automatically runs `patches-relock` and `patches-repatch` to ensure your project is up-to-date with the new configuration. + ## Credits Stephan Zeidler for [Ramsalt Lab AS](https://ramsalt.com) diff --git a/src/Composer/CommandProvider.php b/src/Composer/CommandProvider.php index 54a0bff..9900c7a 100644 --- a/src/Composer/CommandProvider.php +++ b/src/Composer/CommandProvider.php @@ -16,6 +16,7 @@ public function getCommands() { new PatchRemoveCommand(), new PatchListCommand(), new PatchMoveToLocalCommand(), + new PatchMigrateCommand(), ]; } } diff --git a/src/Composer/PatchMigrateCommand.php b/src/Composer/PatchMigrateCommand.php new file mode 100644 index 0000000..c067614 --- /dev/null +++ b/src/Composer/PatchMigrateCommand.php @@ -0,0 +1,126 @@ +setName('patch-migrate-config') + ->setDescription('Migrate Composer Patches 1 configuration to Composer Patches 2.'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $extra = $this->requireComposer()->getPackage()->getExtra(); + $patchType = $this->getPatchType(); + + if (isset($extra['composer-patches'])) { + throw new PatchMigrateConfigurationExistsException('Composer Patches 2 configuration already exists.'); + } + + if ($patchType !== self::PATCHTYPE_ROOT_CP1 && $patchType !== self::PATCHTYPE_FILE_CP1) { + throw new PatchMigrateNoConfigurationFoundException('No Composer Patches 1 configuration found to migrate.'); + } + + $composer_filename = 'composer.json'; + $composer_file = new JsonFile($composer_filename); + $composer_manipulator = new JsonManipulator(file_get_contents($composer_file->getPath())); + + $patches = $this->grabPatches(); + + if ($patchType === self::PATCHTYPE_ROOT_CP1) { + $output->writeln('Migrating patches from root composer.json...'); + + // Move patches to the new location. + $composer_manipulator->removeSubNode('extra', 'patches'); + $composer_manipulator->addSubNode('extra', 'composer-patches.patches', $patches); + + // Handle patches-ignore -> ignore-dependency-patches + if (isset($extra['patches-ignore'])) { + $ignored = []; + foreach ($extra['patches-ignore'] as $package => $package_patches) { + // CP1 patches-ignore format is slightly different, but often it was just a list of patches. + // "patches-ignore": { "source/package": { "target/package": { "description": "url" } } } + // CP2 ignore-dependency-patches is just a list of packages whose patches should be ignored. + // "ignore-dependency-patches": ["some/package"] + $ignored[] = $package; + } + $composer_manipulator->removeSubNode('extra', 'patches-ignore'); + $composer_manipulator->addSubNode('extra', 'composer-patches.ignore-dependency-patches', array_unique($ignored)); + } + + // Handle patchLevel -> package-depths + if (isset($extra['patchLevel'])) { + $depths = []; + foreach ($extra['patchLevel'] as $package => $level) { + // Convert -p1 to 1 + $depths[$package] = (int) str_replace('-p', '', $level); + } + $composer_manipulator->removeSubNode('extra', 'patchLevel'); + $composer_manipulator->addSubNode('extra', 'composer-patches.package-depths', $depths); + } + + // Handle composer-exit-on-patch-failure -> exit-on-patch-failure + if (isset($extra['composer-exit-on-patch-failure'])) { + $composer_manipulator->removeSubNode('extra', 'composer-exit-on-patch-failure'); + $composer_manipulator->addSubNode('extra', 'composer-patches.exit-on-patch-failure', $extra['composer-exit-on-patch-failure']); + } + + // Handle composer-patches-skip-reporting -> skip-reporting + if (isset($extra['composer-patches-skip-reporting'])) { + $composer_manipulator->removeSubNode('extra', 'composer-patches-skip-reporting'); + $composer_manipulator->addSubNode('extra', 'composer-patches.skip-reporting', $extra['composer-patches-skip-reporting']); + } + + // Handle enable-patching (cleanup) + if (isset($extra['enable-patching'])) { + $composer_manipulator->removeSubNode('extra', 'enable-patching'); + } + + // Store the manipulated JSON file. + if (!file_put_contents($composer_filename, $composer_manipulator->getContents())) { + throw new \Exception('Composer file could not be saved.'); + } + } + elseif ($patchType === self::PATCHTYPE_FILE_CP1) { + $patches_filename = $extra['patches-file']; + $output->writeln("Migrating patches from $patches_filename..."); + + // Update composer.json to use the new patches-file location. + $composer_manipulator->removeSubNode('extra', 'patches-file'); + $composer_manipulator->addSubNode('extra', 'composer-patches.patches-file', $patches_filename); + + if (!file_put_contents($composer_filename, $composer_manipulator->getContents())) { + throw new \Exception('Composer file could not be saved.'); + } + + // Update the patches file itself. + $patches_file = new JsonFile($patches_filename); + $patches_manipulator = new JsonManipulator(file_get_contents($patches_file->getPath())); + // CP2 expects patches to be in the root "patches" key of the patches-file, which is the same as CP1. + // So no changes might be needed to the content of the file itself if it only contains "patches". + // However, we should check if there's anything else in there. + } + + $output->writeln('Migration completed successfully.'); + + $application = $this->getApplication(); + $application->setAutoExit(FALSE); + $output->writeln('Relocking patches...'); + $application->run(new ArrayInput(['command' => 'patches-relock']), $output); + + $output->writeln('Repatching dependencies...'); + $application->run(new ArrayInput(['command' => 'patches-repatch']), $output); + + return 0; + } +} diff --git a/src/Exception/PatchMigrateConfigurationExistsException.php b/src/Exception/PatchMigrateConfigurationExistsException.php new file mode 100644 index 0000000..b326bbb --- /dev/null +++ b/src/Exception/PatchMigrateConfigurationExistsException.php @@ -0,0 +1,9 @@ + 'test/project', + 'extra' => [ + 'patches' => [ + 'vendor/package' => [ + 'description' => 'https://example.com/patch.patch', + ], + ], + 'patches-ignore' => [ + 'dependency/package' => [ + 'vendor/package' => [ + 'ignored patch' => 'https://example.com/ignored.patch' + ] + ], + ], + 'patchLevel' => [ + 'vendor/package' => '-p2', + ], + 'composer-exit-on-patch-failure' => true, + 'enable-patching' => true, + ], + ]; + file_put_contents($this->composerJsonPath, json_encode($composer_json, JSON_PRETTY_PRINT)); + + $commandTester = $this->getCommandTester(PatchMigrateCommand::class); + $commandTester->execute([]); + + $this->assertStringContainsString('Migrating patches from root composer.json...', $commandTester->getDisplay()); + $this->assertStringContainsString('Migration completed successfully.', $commandTester->getDisplay()); + $this->assertStringContainsString('Relocking patches...', $commandTester->getDisplay()); + $this->assertStringContainsString('Repatching dependencies...', $commandTester->getDisplay()); + + $updated_composer_json = json_decode(file_get_contents($this->composerJsonPath), TRUE); + + $this->assertArrayNotHasKey('patches', $updated_composer_json['extra']); + $this->assertArrayHasKey('composer-patches', $updated_composer_json['extra']); + $this->assertEquals($composer_json['extra']['patches'], $updated_composer_json['extra']['composer-patches']['patches']); + + $this->assertArrayNotHasKey('patches-ignore', $updated_composer_json['extra']); + $this->assertEquals(['dependency/package'], $updated_composer_json['extra']['composer-patches']['ignore-dependency-patches']); + + $this->assertArrayNotHasKey('patchLevel', $updated_composer_json['extra']); + $this->assertEquals(['vendor/package' => 2], $updated_composer_json['extra']['composer-patches']['package-depths']); + + $this->assertArrayNotHasKey('composer-exit-on-patch-failure', $updated_composer_json['extra']); + $this->assertTrue($updated_composer_json['extra']['composer-patches']['exit-on-patch-failure']); + + $this->assertArrayNotHasKey('enable-patching', $updated_composer_json['extra']); + } + + /** + * Tests migrating patches from an external patches file. + */ + public function testMigrateFile() { + $patches_file = 'patches.json'; + $composer_json = [ + 'name' => 'test/project', + 'extra' => [ + 'patches-file' => $patches_file, + ], + ]; + file_put_contents($this->composerJsonPath, json_encode($composer_json, JSON_PRETTY_PRINT)); + + $patches_json = [ + 'patches' => [ + 'vendor/package' => [ + 'description' => 'https://example.com/patch.patch', + ], + ], + ]; + file_put_contents($this->tempDir . '/' . $patches_file, json_encode($patches_json, JSON_PRETTY_PRINT)); + + $commandTester = $this->getCommandTester(PatchMigrateCommand::class); + $commandTester->execute([]); + + $this->assertStringContainsString("Migrating patches from $patches_file...", $commandTester->getDisplay()); + $this->assertStringContainsString('Migration completed successfully.', $commandTester->getDisplay()); + $this->assertStringContainsString('Relocking patches...', $commandTester->getDisplay()); + $this->assertStringContainsString('Repatching dependencies...', $commandTester->getDisplay()); + + $updated_composer_json = json_decode(file_get_contents($this->composerJsonPath), TRUE); + $this->assertArrayNotHasKey('patches-file', $updated_composer_json['extra']); + $this->assertEquals($patches_file, $updated_composer_json['extra']['composer-patches']['patches-file']); + } + + /** + * Tests that an exception is thrown when no migration is needed. + */ + public function testNoMigrationNeeded() { + $composer_json = [ + 'name' => 'test/project', + 'extra' => [], + ]; + file_put_contents($this->composerJsonPath, json_encode($composer_json, JSON_PRETTY_PRINT)); + + $commandTester = $this->getCommandTester(PatchMigrateCommand::class); + $this->expectException(PatchMigrateNoConfigurationFoundException::class); + $commandTester->execute([]); + } + + /** + * Tests that an exception is thrown when CP2 configuration already exists. + */ + public function testAlreadyHasCP2() { + $composer_json = [ + 'name' => 'test/project', + 'extra' => [ + 'composer-patches' => [ + 'patches' => [], + ], + ], + ]; + file_put_contents($this->composerJsonPath, json_encode($composer_json, JSON_PRETTY_PRINT)); + + $commandTester = $this->getCommandTester(PatchMigrateCommand::class); + $this->expectException(PatchMigrateConfigurationExistsException::class); + $commandTester->execute([]); + } +}