PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / cweagans / composer-patches / src / Patches.php

Patches.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at vendor/cweagans/composer-patches/src/Patches.php

600 lines 20.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @file
5 * Provides a way to patch Composer packages after installation.
6 */
7
8 namespace cweagans\Composer;
9
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;
27
28 class Patches implements PluginInterface, EventSubscriberInterface {
29
30 /**
31 * @var Composer $composer
32 */
33 protected $composer;
34 /**
35 * @var IOInterface $io
36 */
37 protected $io;
38 /**
39 * @var EventDispatcher $eventDispatcher
40 */
41 protected $eventDispatcher;
42 /**
43 * @var ProcessExecutor $executor
44 */
45 protected $executor;
46 /**
47 * @var array $patches
48 */
49 protected $patches;
50
51 /**
52 * @var array $installedPatches
53 */
54 protected $installedPatches;
55
56 /**
57 * Apply plugin modifications to composer
58 *
59 * @param Composer $composer
60 * @param IOInterface $io
61 */
62 public function activate(Composer $composer, IOInterface $io) {
63 $this->composer = $composer;
64 $this->io = $io;
65 $this->eventDispatcher = $composer->getEventDispatcher();
66 $this->executor = new ProcessExecutor($this->io);
67 $this->patches = array();
68 $this->installedPatches = array();
69 }
70
71 /**
72 * Returns an array of event names this subscriber wants to listen to.
73 */
74 public static function getSubscribedEvents() {
75 return array(
76 ScriptEvents::PRE_INSTALL_CMD => array('checkPatches'),
77 ScriptEvents::PRE_UPDATE_CMD => array('checkPatches'),
78 PackageEvents::PRE_PACKAGE_INSTALL => array('gatherPatches'),
79 PackageEvents::PRE_PACKAGE_UPDATE => array('gatherPatches'),
80 // The following is a higher weight for compatibility with
81 // https://github.com/AydinHassan/magento-core-composer-installer and more generally for compatibility with
82 // every Composer plugin which deploys downloaded packages to other locations.
83 // In such cases you want that those plugins deploy patched files so they have to run after
84 // the "composer-patches" plugin.
85 // @see: https://github.com/cweagans/composer-patches/pull/153
86 PackageEvents::POST_PACKAGE_INSTALL => array('postInstall', 10),
87 PackageEvents::POST_PACKAGE_UPDATE => array('postInstall', 10),
88 );
89 }
90
91 /**
92 * Before running composer install,
93 * @param Event $event
94 */
95 public function checkPatches(Event $event) {
96 if (!$this->isPatchingEnabled()) {
97 return;
98 }
99
100 try {
101 $repositoryManager = $this->composer->getRepositoryManager();
102 $localRepository = $repositoryManager->getLocalRepository();
103 $installationManager = $this->composer->getInstallationManager();
104 $packages = $localRepository->getPackages();
105
106 $extra = $this->composer->getPackage()->getExtra();
107 $patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array();
108
109 $tmp_patches = $this->grabPatches();
110 foreach ($packages as $package) {
111 $extra = $package->getExtra();
112 if (isset($extra['patches'])) {
113 if (isset($patches_ignore[$package->getName()])) {
114 foreach ($patches_ignore[$package->getName()] as $package_name => $patches) {
115 if (isset($extra['patches'][$package_name])) {
116 $extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches);
117 }
118 }
119 }
120 $this->installedPatches[$package->getName()] = $extra['patches'];
121 }
122 $patches = isset($extra['patches']) ? $extra['patches'] : array();
123 $tmp_patches = $this->arrayMergeRecursiveDistinct($tmp_patches, $patches);
124 }
125
126 if ($tmp_patches == FALSE) {
127 $this->io->write('<info>No patches supplied.</info>');
128 return;
129 }
130
131 // Remove packages for which the patch set has changed.
132 $promises = array();
133 foreach ($packages as $package) {
134 if (!($package instanceof AliasPackage)) {
135 $package_name = $package->getName();
136 $extra = $package->getExtra();
137 $has_patches = isset($tmp_patches[$package_name]);
138 $has_applied_patches = isset($extra['patches_applied']) && count($extra['patches_applied']) > 0;
139 if (($has_patches && !$has_applied_patches)
140 || (!$has_patches && $has_applied_patches)
141 || ($has_patches && $has_applied_patches && $tmp_patches[$package_name] !== $extra['patches_applied'])) {
142 $uninstallOperation = new UninstallOperation($package, 'Removing package so it can be re-installed and re-patched.');
143 $this->io->write('<info>Removing package ' . $package_name . ' so that it can be re-installed and re-patched.</info>');
144 $promises[] = $installationManager->uninstall($localRepository, $uninstallOperation);
145 }
146 }
147 }
148 $promises = array_filter($promises);
149 if ($promises) {
150 $this->composer->getLoop()->wait($promises);
151 }
152 }
153 // If the Locker isn't available, then we don't need to do this.
154 // It's the first time packages have been installed.
155 catch (\LogicException $e) {
156 return;
157 }
158 }
159
160 /**
161 * Gather patches from dependencies and store them for later use.
162 *
163 * @param PackageEvent $event
164 */
165 public function gatherPatches(PackageEvent $event) {
166 // If we've already done this, then don't do it again.
167 if (isset($this->patches['_patchesGathered'])) {
168 $this->io->write('<info>Patches already gathered. Skipping</info>', TRUE, IOInterface::VERBOSE);
169 return;
170 }
171 // If patching has been disabled, bail out here.
172 elseif (!$this->isPatchingEnabled()) {
173 $this->io->write('<info>Patching is disabled. Skipping.</info>', TRUE, IOInterface::VERBOSE);
174 return;
175 }
176
177 $this->patches = $this->grabPatches();
178 if (empty($this->patches)) {
179 $this->io->write('<info>No patches supplied.</info>');
180 }
181
182 $extra = $this->composer->getPackage()->getExtra();
183 $patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array();
184
185 // Now add all the patches from dependencies that will be installed.
186 $operations = $event->getOperations();
187 $this->io->write('<info>Gathering patches for dependencies. This might take a minute.</info>');
188 foreach ($operations as $operation) {
189 if ($operation instanceof InstallOperation || $operation instanceof UpdateOperation) {
190 $package = $this->getPackageFromOperation($operation);
191 $extra = $package->getExtra();
192 if (isset($extra['patches'])) {
193 if (isset($patches_ignore[$package->getName()])) {
194 foreach ($patches_ignore[$package->getName()] as $package_name => $patches) {
195 if (isset($extra['patches'][$package_name])) {
196 $extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches);
197 }
198 }
199 }
200 $this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $extra['patches']);
201 }
202 // Unset installed patches for this package
203 if(isset($this->installedPatches[$package->getName()])) {
204 unset($this->installedPatches[$package->getName()]);
205 }
206 }
207 }
208
209 // Merge installed patches from dependencies that did not receive an update.
210 foreach ($this->installedPatches as $patches) {
211 $this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $patches);
212 }
213
214 // If we're in verbose mode, list the projects we're going to patch.
215 if ($this->io->isVerbose()) {
216 foreach ($this->patches as $package => $patches) {
217 $number = count($patches);
218 $this->io->write('<info>Found ' . $number . ' patches for ' . $package . '.</info>');
219 }
220 }
221
222 // Make sure we don't gather patches again. Extra keys in $this->patches
223 // won't hurt anything, so we'll just stash it there.
224 $this->patches['_patchesGathered'] = TRUE;
225 }
226
227 /**
228 * Get the patches from root composer or external file
229 * @return Patches
230 * @throws \Exception
231 */
232 public function grabPatches() {
233 // First, try to get the patches from the root composer.json.
234 $extra = $this->composer->getPackage()->getExtra();
235 if (isset($extra['patches'])) {
236 $this->io->write('<info>Gathering patches for root package.</info>');
237 $patches = $extra['patches'];
238 return $patches;
239 }
240 // If it's not specified there, look for a patches-file definition.
241 elseif (isset($extra['patches-file'])) {
242 $this->io->write('<info>Gathering patches from patch file.</info>');
243 $patches = file_get_contents($extra['patches-file']);
244 $patches = json_decode($patches, TRUE);
245 $error = json_last_error();
246 if ($error != 0) {
247 switch ($error) {
248 case JSON_ERROR_DEPTH:
249 $msg = ' - Maximum stack depth exceeded';
250 break;
251 case JSON_ERROR_STATE_MISMATCH:
252 $msg = ' - Underflow or the modes mismatch';
253 break;
254 case JSON_ERROR_CTRL_CHAR:
255 $msg = ' - Unexpected control character found';
256 break;
257 case JSON_ERROR_SYNTAX:
258 $msg = ' - Syntax error, malformed JSON';
259 break;
260 case JSON_ERROR_UTF8:
261 $msg = ' - Malformed UTF-8 characters, possibly incorrectly encoded';
262 break;
263 default:
264 $msg = ' - Unknown error';
265 break;
266 }
267 throw new \Exception('There was an error in the supplied patches file:' . $msg);
268 }
269 if (isset($patches['patches'])) {
270 $patches = $patches['patches'];
271 return $patches;
272 }
273 elseif(!$patches) {
274 throw new \Exception('There was an error in the supplied patch file');
275 }
276 }
277 else {
278 return array();
279 }
280 }
281
282 /**
283 * @param PackageEvent $event
284 * @throws \Exception
285 */
286 public function postInstall(PackageEvent $event) {
287
288 // Check if we should exit in failure.
289 $extra = $this->composer->getPackage()->getExtra();
290 $exitOnFailure = getenv('COMPOSER_EXIT_ON_PATCH_FAILURE') || !empty($extra['composer-exit-on-patch-failure']);
291 $skipReporting = getenv('COMPOSER_PATCHES_SKIP_REPORTING') || !empty($extra['composer-patches-skip-reporting']);
292
293 // Get the package object for the current operation.
294 $operation = $event->getOperation();
295 /** @var PackageInterface $package */
296 $package = $this->getPackageFromOperation($operation);
297 $package_name = $package->getName();
298
299 if (!isset($this->patches[$package_name])) {
300 if ($this->io->isVerbose()) {
301 $this->io->write('<info>No patches found for ' . $package_name . '.</info>');
302 }
303 return;
304 }
305 $this->io->write(' - Applying patches for <info>' . $package_name . '</info>');
306
307 // Get the install path from the package object.
308 $manager = $event->getComposer()->getInstallationManager();
309 $install_path = $manager->getInstaller($package->getType())->getInstallPath($package);
310
311 // Set up a downloader.
312 $downloader = new RemoteFilesystem($this->io, $this->composer->getConfig());
313
314 // Track applied patches in the package info in installed.json
315 $localRepository = $this->composer->getRepositoryManager()->getLocalRepository();
316 $localPackage = $localRepository->findPackage($package_name, $package->getVersion());
317 $extra = $localPackage->getExtra();
318 $extra['patches_applied'] = array();
319
320 foreach ($this->patches[$package_name] as $description => $url) {
321 $this->io->write(' <info>' . $url . '</info> (<comment>' . $description. '</comment>)');
322 try {
323 $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::PRE_PATCH_APPLY, $package, $url, $description));
324 $this->getAndApplyPatch($downloader, $install_path, $url, $package);
325 $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::POST_PATCH_APPLY, $package, $url, $description));
326 $extra['patches_applied'][$description] = $url;
327 }
328 catch (\Exception $e) {
329 $this->io->write(' <error>Could not apply patch! Skipping. The error was: ' . $e->getMessage() . '</error>');
330 if ($exitOnFailure) {
331 throw new \Exception("Cannot apply patch $description ($url)!");
332 }
333 }
334 }
335 $localPackage->setExtra($extra);
336
337 $this->io->write('');
338
339 if (true !== $skipReporting) {
340 $this->writePatchReport($this->patches[$package_name], $install_path);
341 }
342 }
343
344 /**
345 * Get a Package object from an OperationInterface object.
346 *
347 * @param OperationInterface $operation
348 * @return PackageInterface
349 * @throws \Exception
350 */
351 protected function getPackageFromOperation(OperationInterface $operation) {
352 if ($operation instanceof InstallOperation) {
353 $package = $operation->getPackage();
354 }
355 elseif ($operation instanceof UpdateOperation) {
356 $package = $operation->getTargetPackage();
357 }
358 else {
359 throw new \Exception('Unknown operation: ' . get_class($operation));
360 }
361
362 return $package;
363 }
364
365 /**
366 * Apply a patch on code in the specified directory.
367 *
368 * @param RemoteFilesystem $downloader
369 * @param $install_path
370 * @param $patch_url
371 * @param PackageInterface $package
372 * @throws \Exception
373 */
374 protected function getAndApplyPatch(RemoteFilesystem $downloader, $install_path, $patch_url, PackageInterface $package) {
375
376 // Local patch file.
377 if (file_exists($patch_url)) {
378 $filename = realpath($patch_url);
379 }
380 else {
381 // Generate random (but not cryptographically so) filename.
382 $filename = uniqid(sys_get_temp_dir().'/') . ".patch";
383
384 // Download file from remote filesystem to this location.
385 $hostname = parse_url($patch_url, PHP_URL_HOST);
386
387 try {
388 $downloader->copy($hostname, $patch_url, $filename, false);
389 } catch (\Exception $e) {
390 // In case of an exception, retry once as the download might
391 // have failed due to intermittent network issues.
392 $downloader->copy($hostname, $patch_url, $filename, false);
393 }
394 }
395
396 // The order here is intentional. p1 is most likely to apply with git apply.
397 // p0 is next likely. p2 is extremely unlikely, but for some special cases,
398 // it might be useful. p4 is useful for Magento 2 patches
399 $patch_levels = array('-p1', '-p0', '-p2', '-p4');
400
401 // Check for specified patch level for this package.
402 $extra = $this->composer->getPackage()->getExtra();
403 if (!empty($extra['patchLevel'][$package->getName()])){
404 $patch_levels = array($extra['patchLevel'][$package->getName()]);
405 }
406 // Attempt to apply with git apply.
407 $patched = $this->applyPatchWithGit($install_path, $patch_levels, $filename);
408
409 // In some rare cases, git will fail to apply a patch, fallback to using
410 // the 'patch' command.
411 if (!$patched) {
412 foreach ($patch_levels as $patch_level) {
413 // --no-backup-if-mismatch here is a hack that fixes some
414 // differences between how patch works on windows and unix.
415 if ($patched = $this->executeCommand("patch %s --no-backup-if-mismatch -d %s < %s", $patch_level, $install_path, $filename)) {
416 break;
417 }
418 }
419 }
420
421 // Clean up the temporary patch file.
422 if (isset($hostname)) {
423 unlink($filename);
424 }
425 // If the patch *still* isn't applied, then give up and throw an Exception.
426 // Otherwise, let the user know it worked.
427 if (!$patched) {
428 throw new \Exception("Cannot apply patch $patch_url");
429 }
430 }
431
432 /**
433 * Checks if the root package enables patching.
434 *
435 * @return bool
436 * Whether patching is enabled. Defaults to TRUE.
437 */
438 protected function isPatchingEnabled() {
439 $extra = $this->composer->getPackage()->getExtra();
440
441 if (empty($extra['patches']) && empty($extra['patches-ignore']) && !isset($extra['patches-file'])) {
442 // The root package has no patches of its own, so only allow patching if
443 // it has specifically opted in.
444 return isset($extra['enable-patching']) ? $extra['enable-patching'] : FALSE;
445 }
446 else {
447 return TRUE;
448 }
449 }
450
451 /**
452 * Writes a patch report to the target directory.
453 *
454 * @param array $patches
455 * @param string $directory
456 */
457 protected function writePatchReport($patches, $directory) {
458 $output = "This file was automatically generated by Composer Patches (https://github.com/cweagans/composer-patches)\n";
459 $output .= "Patches applied to this directory:\n\n";
460 foreach ($patches as $description => $url) {
461 $output .= $description . "\n";
462 $output .= 'Source: ' . $url . "\n\n\n";
463 }
464 file_put_contents($directory . "/PATCHES.txt", $output);
465 }
466
467 /**
468 * Executes a shell command with escaping.
469 *
470 * @param string $cmd
471 * @return bool
472 */
473 protected function executeCommand($cmd) {
474 // Shell-escape all arguments except the command.
475 $args = func_get_args();
476 foreach ($args as $index => $arg) {
477 if ($index !== 0) {
478 $args[$index] = escapeshellarg($arg);
479 }
480 }
481
482 // And replace the arguments.
483 $command = call_user_func_array('sprintf', $args);
484 $output = '';
485 if ($this->io->isVerbose()) {
486 $this->io->write('<comment>' . $command . '</comment>');
487 $io = $this->io;
488 $output = function ($type, $data) use ($io) {
489 if ($type == Process::ERR) {
490 $io->write('<error>' . $data . '</error>');
491 }
492 else {
493 $io->write('<comment>' . $data . '</comment>');
494 }
495 };
496 }
497 return ($this->executor->execute($command, $output) == 0);
498 }
499
500 /**
501 * Recursively merge arrays without changing data types of values.
502 *
503 * Does not change the data types of the values in the arrays. Matching keys'
504 * values in the second array overwrite those in the first array, as is the
505 * case with array_merge.
506 *
507 * @param array $array1
508 * The first array.
509 * @param array $array2
510 * The second array.
511 * @return array
512 * The merged array.
513 *
514 * @see http://php.net/manual/en/function.array-merge-recursive.php#92195
515 */
516 protected function arrayMergeRecursiveDistinct(array $array1, array $array2) {
517 $merged = $array1;
518
519 foreach ($array2 as $key => &$value) {
520 if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
521 $merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
522 }
523 else {
524 $merged[$key] = $value;
525 }
526 }
527
528 return $merged;
529 }
530
531 /**
532 * Attempts to apply a patch with git apply.
533 *
534 * @param $install_path
535 * @param $patch_levels
536 * @param $filename
537 *
538 * @return bool
539 * TRUE if patch was applied, FALSE otherwise.
540 */
541 protected function applyPatchWithGit($install_path, $patch_levels, $filename) {
542 // Do not use git apply unless the install path is itself a git repo
543 // @see https://stackoverflow.com/a/27283285
544 if (!is_dir($install_path . '/.git')) {
545 return FALSE;
546 }
547
548 $patched = FALSE;
549 foreach ($patch_levels as $patch_level) {
550 if ($this->io->isVerbose()) {
551 $comment = 'Testing ability to patch with git apply.';
552 $comment .= ' This command may produce errors that can be safely ignored.';
553 $this->io->write('<comment>' . $comment . '</comment>');
554 }
555 $checked = $this->executeCommand('git -C %s apply --check -v %s %s', $install_path, $patch_level, $filename);
556 $output = $this->executor->getErrorOutput();
557 if (substr($output, 0, 7) == 'Skipped') {
558 // Git will indicate success but silently skip patches in some scenarios.
559 //
560 // @see https://github.com/cweagans/composer-patches/pull/165
561 $checked = FALSE;
562 }
563 if ($checked) {
564 // Apply the first successful style.
565 $patched = $this->executeCommand('git -C %s apply %s %s', $install_path, $patch_level, $filename);
566 break;
567 }
568 }
569 return $patched;
570 }
571
572 /**
573 * Indicates if a package has been patched.
574 *
575 * @param \Composer\Package\PackageInterface $package
576 * The package to check.
577 *
578 * @return bool
579 * TRUE if the package has been patched.
580 */
581 public static function isPackagePatched(PackageInterface $package) {
582 return array_key_exists('patches_applied', $package->getExtra());
583 }
584
585 /**
586 * {@inheritDoc}
587 */
588 public function deactivate(Composer $composer, IOInterface $io)
589 {
590 }
591
592 /**
593 * {@inheritDoc}
594 */
595 public function uninstall(Composer $composer, IOInterface $io)
596 {
597 }
598
599 }
600