5 * Provides a way to patch Composer packages after installation.
8 namespace cweagans\Composer;
10 use Composer\Composer;
11 use Composer\DependencyResolver\Operation\InstallOperation;
12 use Composer\DependencyResolver\Operation\UninstallOperation;
13 use Composer\DependencyResolver\Operation\UpdateOperation;
14 use Composer\DependencyResolver\Operation\OperationInterface;
15 use Composer\EventDispatcher\EventSubscriberInterface;
16 use Composer\IO\IOInterface;
17 use Composer\Package\AliasPackage;
18 use Composer\Package\PackageInterface;
19 use Composer\Plugin\PluginInterface;
20 use Composer\Installer\PackageEvents;
21 use Composer\Script\Event;
22 use Composer\Script\ScriptEvents;
23 use Composer\Installer\PackageEvent;
24 use Composer\Util\ProcessExecutor;
25 use Composer\Util\RemoteFilesystem;
26 use Symfony\Component\Process\Process;
28 class Patches implements PluginInterface, EventSubscriberInterface {
31 * @var Composer $composer
35 * @var IOInterface $io
39 * @var EventDispatcher $eventDispatcher
41 protected $eventDispatcher;
43 * @var ProcessExecutor $executor
52 * Apply plugin modifications to composer
54 * @param Composer $composer
55 * @param IOInterface $io
57 public function activate(Composer $composer, IOInterface $io) {
58 $this->composer = $composer;
60 $this->eventDispatcher = $composer->getEventDispatcher();
61 $this->executor = new ProcessExecutor($this->io);
62 $this->patches = array();
63 $this->installedPatches = array();
67 * Returns an array of event names this subscriber wants to listen to.
69 public static function getSubscribedEvents() {
71 ScriptEvents::PRE_INSTALL_CMD => array('checkPatches'),
72 ScriptEvents::PRE_UPDATE_CMD => array('checkPatches'),
73 PackageEvents::PRE_PACKAGE_INSTALL => array('gatherPatches'),
74 PackageEvents::PRE_PACKAGE_UPDATE => array('gatherPatches'),
75 // The following is a higher weight for compatibility with
76 // https://github.com/AydinHassan/magento-core-composer-installer and more generally for compatibility with
77 // every Composer plugin which deploys downloaded packages to other locations.
78 // In such cases you want that those plugins deploy patched files so they have to run after
79 // the "composer-patches" plugin.
80 // @see: https://github.com/cweagans/composer-patches/pull/153
81 PackageEvents::POST_PACKAGE_INSTALL => array('postInstall', 10),
82 PackageEvents::POST_PACKAGE_UPDATE => array('postInstall', 10),
87 * Before running composer install,
90 public function checkPatches(Event $event) {
91 if (!$this->isPatchingEnabled()) {
96 $repositoryManager = $this->composer->getRepositoryManager();
97 $localRepository = $repositoryManager->getLocalRepository();
98 $installationManager = $this->composer->getInstallationManager();
99 $packages = $localRepository->getPackages();
101 $tmp_patches = $this->grabPatches();
102 foreach ($packages as $package) {
103 $extra = $package->getExtra();
104 if (isset($extra['patches'])) {
105 $this->installedPatches[$package->getName()] = $extra['patches'];
107 $patches = isset($extra['patches']) ? $extra['patches'] : array();
108 $tmp_patches = array_merge_recursive($tmp_patches, $patches);
111 if ($tmp_patches == FALSE) {
112 $this->io->write('<info>No patches supplied.</info>');
116 // Remove packages for which the patch set has changed.
117 foreach ($packages as $package) {
118 if (!($package instanceof AliasPackage)) {
119 $package_name = $package->getName();
120 $extra = $package->getExtra();
121 $has_patches = isset($tmp_patches[$package_name]);
122 $has_applied_patches = isset($extra['patches_applied']);
123 if (($has_patches && !$has_applied_patches)
124 || (!$has_patches && $has_applied_patches)
125 || ($has_patches && $has_applied_patches && $tmp_patches[$package_name] !== $extra['patches_applied'])) {
126 $uninstallOperation = new UninstallOperation($package, 'Removing package so it can be re-installed and re-patched.');
127 $this->io->write('<info>Removing package ' . $package_name . ' so that it can be re-installed and re-patched.</info>');
128 $installationManager->uninstall($localRepository, $uninstallOperation);
133 // If the Locker isn't available, then we don't need to do this.
134 // It's the first time packages have been installed.
135 catch (\LogicException $e) {
141 * Gather patches from dependencies and store them for later use.
143 * @param PackageEvent $event
145 public function gatherPatches(PackageEvent $event) {
146 // If we've already done this, then don't do it again.
147 if (isset($this->patches['_patchesGathered'])) {
148 $this->io->write('<info>Patches already gathered. Skipping</info>', TRUE, IOInterface::VERBOSE);
151 // If patching has been disabled, bail out here.
152 elseif (!$this->isPatchingEnabled()) {
153 $this->io->write('<info>Patching is disabled. Skipping.</info>', TRUE, IOInterface::VERBOSE);
157 $this->patches = $this->grabPatches();
158 if (empty($this->patches)) {
159 $this->io->write('<info>No patches supplied.</info>');
162 $extra = $this->composer->getPackage()->getExtra();
163 $patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array();
165 // Now add all the patches from dependencies that will be installed.
166 $operations = $event->getOperations();
167 $this->io->write('<info>Gathering patches for dependencies. This might take a minute.</info>');
168 foreach ($operations as $operation) {
169 if ($operation->getJobType() == 'install' || $operation->getJobType() == 'update') {
170 $package = $this->getPackageFromOperation($operation);
171 $extra = $package->getExtra();
172 if (isset($extra['patches'])) {
173 if (isset($patches_ignore[$package->getName()])) {
174 foreach ($patches_ignore[$package->getName()] as $package_name => $patches) {
175 if (isset($extra['patches'][$package_name])) {
176 $extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches);
180 $this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $extra['patches']);
182 // Unset installed patches for this package
183 if(isset($this->installedPatches[$package->getName()])) {
184 unset($this->installedPatches[$package->getName()]);
189 // Merge installed patches from dependencies that did not receive an update.
190 foreach ($this->installedPatches as $patches) {
191 $this->patches = array_merge_recursive($this->patches, $patches);
194 // If we're in verbose mode, list the projects we're going to patch.
195 if ($this->io->isVerbose()) {
196 foreach ($this->patches as $package => $patches) {
197 $number = count($patches);
198 $this->io->write('<info>Found ' . $number . ' patches for ' . $package . '.</info>');
202 // Make sure we don't gather patches again. Extra keys in $this->patches
203 // won't hurt anything, so we'll just stash it there.
204 $this->patches['_patchesGathered'] = TRUE;
208 * Get the patches from root composer or external file
212 public function grabPatches() {
213 // First, try to get the patches from the root composer.json.
214 $extra = $this->composer->getPackage()->getExtra();
215 if (isset($extra['patches'])) {
216 $this->io->write('<info>Gathering patches for root package.</info>');
217 $patches = $extra['patches'];
220 // If it's not specified there, look for a patches-file definition.
221 elseif (isset($extra['patches-file'])) {
222 $this->io->write('<info>Gathering patches from patch file.</info>');
223 $patches = file_get_contents($extra['patches-file']);
224 $patches = json_decode($patches, TRUE);
225 $error = json_last_error();
228 case JSON_ERROR_DEPTH:
229 $msg = ' - Maximum stack depth exceeded';
231 case JSON_ERROR_STATE_MISMATCH:
232 $msg = ' - Underflow or the modes mismatch';
234 case JSON_ERROR_CTRL_CHAR:
235 $msg = ' - Unexpected control character found';
237 case JSON_ERROR_SYNTAX:
238 $msg = ' - Syntax error, malformed JSON';
240 case JSON_ERROR_UTF8:
241 $msg = ' - Malformed UTF-8 characters, possibly incorrectly encoded';
244 $msg = ' - Unknown error';
247 throw new \Exception('There was an error in the supplied patches file:' . $msg);
249 if (isset($patches['patches'])) {
250 $patches = $patches['patches'];
254 throw new \Exception('There was an error in the supplied patch file');
263 * @param PackageEvent $event
266 public function postInstall(PackageEvent $event) {
268 // Check if we should exit in failure.
269 $extra = $this->composer->getPackage()->getExtra();
270 $exitOnFailure = getenv('COMPOSER_EXIT_ON_PATCH_FAILURE') || !empty($extra['composer-exit-on-patch-failure']);
272 // Get the package object for the current operation.
273 $operation = $event->getOperation();
274 /** @var PackageInterface $package */
275 $package = $this->getPackageFromOperation($operation);
276 $package_name = $package->getName();
278 if (!isset($this->patches[$package_name])) {
279 if ($this->io->isVerbose()) {
280 $this->io->write('<info>No patches found for ' . $package_name . '.</info>');
284 $this->io->write(' - Applying patches for <info>' . $package_name . '</info>');
286 // Get the install path from the package object.
287 $manager = $event->getComposer()->getInstallationManager();
288 $install_path = $manager->getInstaller($package->getType())->getInstallPath($package);
290 // Set up a downloader.
291 $downloader = new RemoteFilesystem($this->io, $this->composer->getConfig());
293 // Track applied patches in the package info in installed.json
294 $localRepository = $this->composer->getRepositoryManager()->getLocalRepository();
295 $localPackage = $localRepository->findPackage($package_name, $package->getVersion());
296 $extra = $localPackage->getExtra();
297 $extra['patches_applied'] = array();
299 foreach ($this->patches[$package_name] as $description => $url) {
300 $this->io->write(' <info>' . $url . '</info> (<comment>' . $description. '</comment>)');
302 $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::PRE_PATCH_APPLY, $package, $url, $description));
303 $this->getAndApplyPatch($downloader, $install_path, $url);
304 $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::POST_PATCH_APPLY, $package, $url, $description));
305 $extra['patches_applied'][$description] = $url;
307 catch (\Exception $e) {
308 $this->io->write(' <error>Could not apply patch! Skipping. The error was: ' . $e->getMessage() . '</error>');
309 if ($exitOnFailure) {
310 throw new \Exception("Cannot apply patch $description ($url)!");
314 $localPackage->setExtra($extra);
316 $this->io->write('');
317 $this->writePatchReport($this->patches[$package_name], $install_path);
321 * Get a Package object from an OperationInterface object.
323 * @param OperationInterface $operation
324 * @return PackageInterface
327 protected function getPackageFromOperation(OperationInterface $operation) {
328 if ($operation instanceof InstallOperation) {
329 $package = $operation->getPackage();
331 elseif ($operation instanceof UpdateOperation) {
332 $package = $operation->getTargetPackage();
335 throw new \Exception('Unknown operation: ' . get_class($operation));
342 * Apply a patch on code in the specified directory.
344 * @param RemoteFilesystem $downloader
345 * @param $install_path
349 protected function getAndApplyPatch(RemoteFilesystem $downloader, $install_path, $patch_url) {
352 if (file_exists($patch_url)) {
353 $filename = realpath($patch_url);
356 // Generate random (but not cryptographically so) filename.
357 $filename = uniqid(sys_get_temp_dir().'/') . ".patch";
359 // Download file from remote filesystem to this location.
360 $hostname = parse_url($patch_url, PHP_URL_HOST);
361 $downloader->copy($hostname, $patch_url, $filename, FALSE);
364 // The order here is intentional. p1 is most likely to apply with git apply.
365 // p0 is next likely. p2 is extremely unlikely, but for some special cases,
366 // it might be useful. p4 is useful for Magento 2 patches
367 $patch_levels = array('-p1', '-p0', '-p2', '-p4');
369 // Attempt to apply with git apply.
370 $patched = $this->applyPatchWithGit($install_path, $patch_levels, $filename);
372 // In some rare cases, git will fail to apply a patch, fallback to using
373 // the 'patch' command.
375 foreach ($patch_levels as $patch_level) {
376 // --no-backup-if-mismatch here is a hack that fixes some
377 // differences between how patch works on windows and unix.
378 if ($patched = $this->executeCommand("patch %s --no-backup-if-mismatch -d %s < %s", $patch_level, $install_path, $filename)) {
384 // Clean up the temporary patch file.
385 if (isset($hostname)) {
388 // If the patch *still* isn't applied, then give up and throw an Exception.
389 // Otherwise, let the user know it worked.
391 throw new \Exception("Cannot apply patch $patch_url");
396 * Checks if the root package enables patching.
399 * Whether patching is enabled. Defaults to TRUE.
401 protected function isPatchingEnabled() {
402 $extra = $this->composer->getPackage()->getExtra();
404 if (empty($extra['patches']) && empty($extra['patches-ignore']) && !isset($extra['patches-file'])) {
405 // The root package has no patches of its own, so only allow patching if
406 // it has specifically opted in.
407 return isset($extra['enable-patching']) ? $extra['enable-patching'] : FALSE;
415 * Writes a patch report to the target directory.
417 * @param array $patches
418 * @param string $directory
420 protected function writePatchReport($patches, $directory) {
421 $output = "This file was automatically generated by Composer Patches (https://github.com/cweagans/composer-patches)\n";
422 $output .= "Patches applied to this directory:\n\n";
423 foreach ($patches as $description => $url) {
424 $output .= $description . "\n";
425 $output .= 'Source: ' . $url . "\n\n\n";
427 file_put_contents($directory . "/PATCHES.txt", $output);
431 * Executes a shell command with escaping.
436 protected function executeCommand($cmd) {
437 // Shell-escape all arguments except the command.
438 $args = func_get_args();
439 foreach ($args as $index => $arg) {
441 $args[$index] = escapeshellarg($arg);
445 // And replace the arguments.
446 $command = call_user_func_array('sprintf', $args);
448 if ($this->io->isVerbose()) {
449 $this->io->write('<comment>' . $command . '</comment>');
451 $output = function ($type, $data) use ($io) {
452 if ($type == Process::ERR) {
453 $io->write('<error>' . $data . '</error>');
456 $io->write('<comment>' . $data . '</comment>');
460 return ($this->executor->execute($command, $output) == 0);
464 * Recursively merge arrays without changing data types of values.
466 * Does not change the data types of the values in the arrays. Matching keys'
467 * values in the second array overwrite those in the first array, as is the
468 * case with array_merge.
470 * @param array $array1
472 * @param array $array2
477 * @see http://php.net/manual/en/function.array-merge-recursive.php#92195
479 protected function arrayMergeRecursiveDistinct(array $array1, array $array2) {
482 foreach ($array2 as $key => &$value) {
483 if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
484 $merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
487 $merged[$key] = $value;
495 * Attempts to apply a patch with git apply.
497 * @param $install_path
498 * @param $patch_levels
502 * TRUE if patch was applied, FALSE otherwise.
504 protected function applyPatchWithGit($install_path, $patch_levels, $filename) {
505 // Do not use git apply unless the install path is itself a git repo
506 // @see https://stackoverflow.com/a/27283285
507 if (!is_dir($install_path . '/.git')) {
512 foreach ($patch_levels as $patch_level) {
513 if ($this->io->isVerbose()) {
514 $comment = 'Testing ability to patch with git apply.';
515 $comment .= ' This command may produce errors that can be safely ignored.';
516 $this->io->write('<comment>' . $comment . '</comment>');
518 $checked = $this->executeCommand('git -C %s apply --check -v %s %s', $install_path, $patch_level, $filename);
519 $output = $this->executor->getErrorOutput();
520 if (substr($output, 0, 7) == 'Skipped') {
521 // Git will indicate success but silently skip patches in some scenarios.
523 // @see https://github.com/cweagans/composer-patches/pull/165
527 // Apply the first successful style.
528 $patched = $this->executeCommand('git -C %s apply %s %s', $install_path, $patch_level, $filename);