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 => "checkPatches",
72 ScriptEvents::PRE_UPDATE_CMD => "checkPatches",
73 PackageEvents::PRE_PACKAGE_INSTALL => "gatherPatches",
74 PackageEvents::PRE_PACKAGE_UPDATE => "gatherPatches",
75 PackageEvents::POST_PACKAGE_INSTALL => "postInstall",
76 PackageEvents::POST_PACKAGE_UPDATE => "postInstall",
81 * Before running composer install,
84 public function checkPatches(Event $event) {
85 if (!$this->isPatchingEnabled()) {
90 $repositoryManager = $this->composer->getRepositoryManager();
91 $localRepository = $repositoryManager->getLocalRepository();
92 $installationManager = $this->composer->getInstallationManager();
93 $packages = $localRepository->getPackages();
95 $tmp_patches = $this->grabPatches();
96 if ($tmp_patches == FALSE) {
97 $this->io->write('<info>No patches supplied.</info>');
101 foreach ($packages as $package) {
102 $extra = $package->getExtra();
103 if (isset($extra['patches'])) {
104 $this->installedPatches[$package->getName()] = $extra['patches'];
106 $patches = isset($extra['patches']) ? $extra['patches'] : array();
107 $tmp_patches = array_merge_recursive($tmp_patches, $patches);
110 // Remove packages for which the patch set has changed.
111 foreach ($packages as $package) {
112 if (!($package instanceof AliasPackage)) {
113 $package_name = $package->getName();
114 $extra = $package->getExtra();
115 $has_patches = isset($tmp_patches[$package_name]);
116 $has_applied_patches = isset($extra['patches_applied']);
117 if (($has_patches && !$has_applied_patches)
118 || (!$has_patches && $has_applied_patches)
119 || ($has_patches && $has_applied_patches && $tmp_patches[$package_name] !== $extra['patches_applied'])) {
120 $uninstallOperation = new UninstallOperation($package, 'Removing package so it can be re-installed and re-patched.');
121 $this->io->write('<info>Removing package ' . $package_name . ' so that it can be re-installed and re-patched.</info>');
122 $installationManager->uninstall($localRepository, $uninstallOperation);
127 // If the Locker isn't available, then we don't need to do this.
128 // It's the first time packages have been installed.
129 catch (\LogicException $e) {
135 * Gather patches from dependencies and store them for later use.
137 * @param PackageEvent $event
139 public function gatherPatches(PackageEvent $event) {
140 // If we've already done this, then don't do it again.
141 if (isset($this->patches['_patchesGathered'])) {
142 $this->io->write('<info>Patches already gathered. Skipping</info>', TRUE, IOInterface::VERBOSE);
145 // If patching has been disabled, bail out here.
146 elseif (!$this->isPatchingEnabled()) {
147 $this->io->write('<info>Patching is disabled. Skipping.</info>', TRUE, IOInterface::VERBOSE);
151 $this->patches = $this->grabPatches();
152 if (empty($this->patches)) {
153 $this->io->write('<info>No patches supplied.</info>');
156 $extra = $this->composer->getPackage()->getExtra();
157 $patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array();
159 // Now add all the patches from dependencies that will be installed.
160 $operations = $event->getOperations();
161 $this->io->write('<info>Gathering patches for dependencies. This might take a minute.</info>');
162 foreach ($operations as $operation) {
163 if ($operation->getJobType() == 'install' || $operation->getJobType() == 'update') {
164 $package = $this->getPackageFromOperation($operation);
165 $extra = $package->getExtra();
166 if (isset($extra['patches'])) {
167 if (isset($patches_ignore[$package->getName()])) {
168 foreach ($patches_ignore[$package->getName()] as $package_name => $patches) {
169 if (isset($extra['patches'][$package_name])) {
170 $extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches);
174 $this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $extra['patches']);
176 // Unset installed patches for this package
177 if(isset($this->installedPatches[$package->getName()])) {
178 unset($this->installedPatches[$package->getName()]);
183 // Merge installed patches from dependencies that did not receive an update.
184 foreach ($this->installedPatches as $patches) {
185 $this->patches = array_merge_recursive($this->patches, $patches);
188 // If we're in verbose mode, list the projects we're going to patch.
189 if ($this->io->isVerbose()) {
190 foreach ($this->patches as $package => $patches) {
191 $number = count($patches);
192 $this->io->write('<info>Found ' . $number . ' patches for ' . $package . '.</info>');
196 // Make sure we don't gather patches again. Extra keys in $this->patches
197 // won't hurt anything, so we'll just stash it there.
198 $this->patches['_patchesGathered'] = TRUE;
202 * Get the patches from root composer or external file
206 public function grabPatches() {
207 // First, try to get the patches from the root composer.json.
208 $extra = $this->composer->getPackage()->getExtra();
209 if (isset($extra['patches'])) {
210 $this->io->write('<info>Gathering patches for root package.</info>');
211 $patches = $extra['patches'];
214 // If it's not specified there, look for a patches-file definition.
215 elseif (isset($extra['patches-file'])) {
216 $this->io->write('<info>Gathering patches from patch file.</info>');
217 $patches = file_get_contents($extra['patches-file']);
218 $patches = json_decode($patches, TRUE);
219 $error = json_last_error();
222 case JSON_ERROR_DEPTH:
223 $msg = ' - Maximum stack depth exceeded';
225 case JSON_ERROR_STATE_MISMATCH:
226 $msg = ' - Underflow or the modes mismatch';
228 case JSON_ERROR_CTRL_CHAR:
229 $msg = ' - Unexpected control character found';
231 case JSON_ERROR_SYNTAX:
232 $msg = ' - Syntax error, malformed JSON';
234 case JSON_ERROR_UTF8:
235 $msg = ' - Malformed UTF-8 characters, possibly incorrectly encoded';
238 $msg = ' - Unknown error';
241 throw new \Exception('There was an error in the supplied patches file:' . $msg);
243 if (isset($patches['patches'])) {
244 $patches = $patches['patches'];
248 throw new \Exception('There was an error in the supplied patch file');
257 * @param PackageEvent $event
260 public function postInstall(PackageEvent $event) {
261 // Get the package object for the current operation.
262 $operation = $event->getOperation();
263 /** @var PackageInterface $package */
264 $package = $this->getPackageFromOperation($operation);
265 $package_name = $package->getName();
267 if (!isset($this->patches[$package_name])) {
268 if ($this->io->isVerbose()) {
269 $this->io->write('<info>No patches found for ' . $package_name . '.</info>');
273 $this->io->write(' - Applying patches for <info>' . $package_name . '</info>');
275 // Get the install path from the package object.
276 $manager = $event->getComposer()->getInstallationManager();
277 $install_path = $manager->getInstaller($package->getType())->getInstallPath($package);
279 // Set up a downloader.
280 $downloader = new RemoteFilesystem($this->io, $this->composer->getConfig());
282 // Track applied patches in the package info in installed.json
283 $localRepository = $this->composer->getRepositoryManager()->getLocalRepository();
284 $localPackage = $localRepository->findPackage($package_name, $package->getVersion());
285 $extra = $localPackage->getExtra();
286 $extra['patches_applied'] = array();
288 foreach ($this->patches[$package_name] as $description => $url) {
289 $this->io->write(' <info>' . $url . '</info> (<comment>' . $description. '</comment>)');
291 $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::PRE_PATCH_APPLY, $package, $url, $description));
292 $this->getAndApplyPatch($downloader, $install_path, $url);
293 $this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::POST_PATCH_APPLY, $package, $url, $description));
294 $extra['patches_applied'][$description] = $url;
296 catch (\Exception $e) {
297 $this->io->write(' <error>Could not apply patch! Skipping. The error was: ' . $e->getMessage() . '</error>');
298 $extra = $this->composer->getPackage()->getExtra();
299 if (getenv('COMPOSER_EXIT_ON_PATCH_FAILURE') || !empty($extra['composer-exit-on-patch-failure'])) {
300 throw new \Exception("Cannot apply patch $description ($url)!");
304 $localPackage->setExtra($extra);
306 $this->io->write('');
307 $this->writePatchReport($this->patches[$package_name], $install_path);
311 * Get a Package object from an OperationInterface object.
313 * @param OperationInterface $operation
314 * @return PackageInterface
317 protected function getPackageFromOperation(OperationInterface $operation) {
318 if ($operation instanceof InstallOperation) {
319 $package = $operation->getPackage();
321 elseif ($operation instanceof UpdateOperation) {
322 $package = $operation->getTargetPackage();
325 throw new \Exception('Unknown operation: ' . get_class($operation));
332 * Apply a patch on code in the specified directory.
334 * @param RemoteFilesystem $downloader
335 * @param $install_path
339 protected function getAndApplyPatch(RemoteFilesystem $downloader, $install_path, $patch_url) {
342 if (file_exists($patch_url)) {
343 $filename = realpath($patch_url);
346 // Generate random (but not cryptographically so) filename.
347 $filename = uniqid(sys_get_temp_dir().'/') . ".patch";
349 // Download file from remote filesystem to this location.
350 $hostname = parse_url($patch_url, PHP_URL_HOST);
351 $downloader->copy($hostname, $patch_url, $filename, FALSE);
354 // Modified from drush6:make.project.inc
356 // The order here is intentional. p1 is most likely to apply with git apply.
357 // p0 is next likely. p2 is extremely unlikely, but for some special cases,
358 // it might be useful.
359 $patch_levels = array('-p1', '-p0', '-p2');
360 foreach ($patch_levels as $patch_level) {
361 $checked = $this->executeCommand('cd %s && git --git-dir=. apply --check %s %s', $install_path, $patch_level, $filename);
363 // Apply the first successful style.
364 $patched = $this->executeCommand('cd %s && git --git-dir=. apply %s %s', $install_path, $patch_level, $filename);
369 // In some rare cases, git will fail to apply a patch, fallback to using
370 // the 'patch' command.
372 foreach ($patch_levels as $patch_level) {
373 // --no-backup-if-mismatch here is a hack that fixes some
374 // differences between how patch works on windows and unix.
375 if ($patched = $this->executeCommand("patch %s --no-backup-if-mismatch -d %s < %s", $patch_level, $install_path, $filename)) {
381 // Clean up the temporary patch file.
382 if (isset($hostname)) {
385 // If the patch *still* isn't applied, then give up and throw an Exception.
386 // Otherwise, let the user know it worked.
388 throw new \Exception("Cannot apply patch $patch_url");
393 * Checks if the root package enables patching.
396 * Whether patching is enabled. Defaults to TRUE.
398 protected function isPatchingEnabled() {
399 $extra = $this->composer->getPackage()->getExtra();
401 if (empty($extra['patches']) && empty($extra['patches-ignore']) && !isset($extra['patches-file'])) {
402 // The root package has no patches of its own, so only allow patching if
403 // it has specifically opted in.
404 return isset($extra['enable-patching']) ? $extra['enable-patching'] : FALSE;
412 * Writes a patch report to the target directory.
414 * @param array $patches
415 * @param string $directory
417 protected function writePatchReport($patches, $directory) {
418 $output = "This file was automatically generated by Composer Patches (https://github.com/cweagans/composer-patches)\n";
419 $output .= "Patches applied to this directory:\n\n";
420 foreach ($patches as $description => $url) {
421 $output .= $description . "\n";
422 $output .= 'Source: ' . $url . "\n\n\n";
424 file_put_contents($directory . "/PATCHES.txt", $output);
428 * Executes a shell command with escaping.
433 protected function executeCommand($cmd) {
434 // Shell-escape all arguments except the command.
435 $args = func_get_args();
436 foreach ($args as $index => $arg) {
438 $args[$index] = escapeshellarg($arg);
442 // And replace the arguments.
443 $command = call_user_func_array('sprintf', $args);
445 if ($this->io->isVerbose()) {
446 $this->io->write('<comment>' . $command . '</comment>');
448 $output = function ($type, $data) use ($io) {
449 if ($type == Process::ERR) {
450 $io->write('<error>' . $data . '</error>');
453 $io->write('<comment>' . $data . '</comment>');
457 return ($this->executor->execute($command, $output) == 0);
461 * Recursively merge arrays without changing data types of values.
463 * Does not change the data types of the values in the arrays. Matching keys'
464 * values in the second array overwrite those in the first array, as is the
465 * case with array_merge.
467 * @param array $array1
469 * @param array $array2
474 * @see http://php.net/manual/en/function.array-merge-recursive.php#92195
476 protected function arrayMergeRecursiveDistinct(array $array1, array $array2) {
479 foreach ($array2 as $key => &$value) {
480 if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
481 $merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
484 $merged[$key] = $value;