Skip to content
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/Composer/CommandProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public function getCommands() {
new PatchRemoveCommand(),
new PatchListCommand(),
new PatchMoveToLocalCommand(),
new PatchMigrateCommand(),
];
}
}
126 changes: 126 additions & 0 deletions src/Composer/PatchMigrateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace szeidler\ComposerPatchesCLI\Composer;

use Composer\Json\JsonFile;
use Composer\Json\JsonManipulator;
use szeidler\ComposerPatchesCLI\Exception\PatchMigrateConfigurationExistsException;
use szeidler\ComposerPatchesCLI\Exception\PatchMigrateNoConfigurationFoundException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class PatchMigrateCommand extends PatchBaseCommand {

protected function configure(): void {
$this->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('<info>Migrating patches from root composer.json...</info>');

// 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("<info>Migrating patches from $patches_filename...</info>");

// 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('<info>Relocking patches...</info>');
$application->run(new ArrayInput(['command' => 'patches-relock']), $output);

$output->writeln('<info>Repatching dependencies...</info>');
$application->run(new ArrayInput(['command' => 'patches-repatch']), $output);

return 0;
}
}
9 changes: 9 additions & 0 deletions src/Exception/PatchMigrateConfigurationExistsException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace szeidler\ComposerPatchesCLI\Exception;

/**
* Exception thrown when Composer Patches 2 configuration already exists.
*/
class PatchMigrateConfigurationExistsException extends PatchMigrateException {
}
9 changes: 9 additions & 0 deletions src/Exception/PatchMigrateException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace szeidler\ComposerPatchesCLI\Exception;

/**
* Base exception for patch migration errors.
*/
class PatchMigrateException extends \Exception {
}
9 changes: 9 additions & 0 deletions src/Exception/PatchMigrateNoConfigurationFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace szeidler\ComposerPatchesCLI\Exception;

/**
* Exception thrown when no Composer Patches 1 configuration is found to migrate.
*/
class PatchMigrateNoConfigurationFoundException extends PatchMigrateException {
}
136 changes: 136 additions & 0 deletions tests/PatchMigrateCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

namespace szeidler\ComposerPatchesCLI\Tests;

use szeidler\ComposerPatchesCLI\Composer\PatchMigrateCommand;
use szeidler\ComposerPatchesCLI\Exception\PatchMigrateConfigurationExistsException;
use szeidler\ComposerPatchesCLI\Exception\PatchMigrateNoConfigurationFoundException;

/**
* Tests the PatchMigrateCommand class.
*/
class PatchMigrateCommandTest extends PatchCommandTestBase {

/**
* Tests migrating patches from the root composer.json.
*/
public function testMigrateRoot() {
$composer_json = [
'name' => '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([]);
}
}