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([]);
+ }
+}