composer = $composer;
$this->io = $io;
$this->eventDispatcher = $composer->getEventDispatcher();
$this->executor = new ProcessExecutor($this->io);
$this->patches = array();
$this->installedPatches = array();
}
/**
* Returns an array of event names this subscriber wants to listen to.
*/
public static function getSubscribedEvents() {
return array(
ScriptEvents::PRE_INSTALL_CMD => "checkPatches",
ScriptEvents::PRE_UPDATE_CMD => "checkPatches",
PackageEvents::PRE_PACKAGE_INSTALL => "gatherPatches",
PackageEvents::PRE_PACKAGE_UPDATE => "gatherPatches",
PackageEvents::POST_PACKAGE_INSTALL => "postInstall",
PackageEvents::POST_PACKAGE_UPDATE => "postInstall",
);
}
/**
* Before running composer install,
* @param Event $event
*/
public function checkPatches(Event $event) {
if (!$this->isPatchingEnabled()) {
return;
}
try {
$repositoryManager = $this->composer->getRepositoryManager();
$localRepository = $repositoryManager->getLocalRepository();
$installationManager = $this->composer->getInstallationManager();
$packages = $localRepository->getPackages();
$tmp_patches = $this->grabPatches();
if ($tmp_patches == FALSE) {
$this->io->write('No patches supplied.');
return;
}
foreach ($packages as $package) {
$extra = $package->getExtra();
if (isset($extra['patches'])) {
$this->installedPatches[$package->getName()] = $extra['patches'];
}
$patches = isset($extra['patches']) ? $extra['patches'] : array();
$tmp_patches = array_merge_recursive($tmp_patches, $patches);
}
// Remove packages for which the patch set has changed.
foreach ($packages as $package) {
if (!($package instanceof AliasPackage)) {
$package_name = $package->getName();
$extra = $package->getExtra();
$has_patches = isset($tmp_patches[$package_name]);
$has_applied_patches = isset($extra['patches_applied']);
if (($has_patches && !$has_applied_patches)
|| (!$has_patches && $has_applied_patches)
|| ($has_patches && $has_applied_patches && $tmp_patches[$package_name] !== $extra['patches_applied'])) {
$uninstallOperation = new UninstallOperation($package, 'Removing package so it can be re-installed and re-patched.');
$this->io->write('Removing package ' . $package_name . ' so that it can be re-installed and re-patched.');
$installationManager->uninstall($localRepository, $uninstallOperation);
}
}
}
}
// If the Locker isn't available, then we don't need to do this.
// It's the first time packages have been installed.
catch (\LogicException $e) {
return;
}
}
/**
* Gather patches from dependencies and store them for later use.
*
* @param PackageEvent $event
*/
public function gatherPatches(PackageEvent $event) {
// If we've already done this, then don't do it again.
if (isset($this->patches['_patchesGathered'])) {
$this->io->write('Patches already gathered. Skipping', TRUE, IOInterface::VERBOSE);
return;
}
// If patching has been disabled, bail out here.
elseif (!$this->isPatchingEnabled()) {
$this->io->write('Patching is disabled. Skipping.', TRUE, IOInterface::VERBOSE);
return;
}
$this->patches = $this->grabPatches();
if (empty($this->patches)) {
$this->io->write('No patches supplied.');
}
$extra = $this->composer->getPackage()->getExtra();
$patches_ignore = isset($extra['patches-ignore']) ? $extra['patches-ignore'] : array();
// Now add all the patches from dependencies that will be installed.
$operations = $event->getOperations();
$this->io->write('Gathering patches for dependencies. This might take a minute.');
foreach ($operations as $operation) {
if ($operation->getJobType() == 'install' || $operation->getJobType() == 'update') {
$package = $this->getPackageFromOperation($operation);
$extra = $package->getExtra();
if (isset($extra['patches'])) {
if (isset($patches_ignore[$package->getName()])) {
foreach ($patches_ignore[$package->getName()] as $package_name => $patches) {
if (isset($extra['patches'][$package_name])) {
$extra['patches'][$package_name] = array_diff($extra['patches'][$package_name], $patches);
}
}
}
$this->patches = $this->arrayMergeRecursiveDistinct($this->patches, $extra['patches']);
}
// Unset installed patches for this package
if(isset($this->installedPatches[$package->getName()])) {
unset($this->installedPatches[$package->getName()]);
}
}
}
// Merge installed patches from dependencies that did not receive an update.
foreach ($this->installedPatches as $patches) {
$this->patches = array_merge_recursive($this->patches, $patches);
}
// If we're in verbose mode, list the projects we're going to patch.
if ($this->io->isVerbose()) {
foreach ($this->patches as $package => $patches) {
$number = count($patches);
$this->io->write('Found ' . $number . ' patches for ' . $package . '.');
}
}
// Make sure we don't gather patches again. Extra keys in $this->patches
// won't hurt anything, so we'll just stash it there.
$this->patches['_patchesGathered'] = TRUE;
}
/**
* Get the patches from root composer or external file
* @return Patches
* @throws \Exception
*/
public function grabPatches() {
// First, try to get the patches from the root composer.json.
$extra = $this->composer->getPackage()->getExtra();
if (isset($extra['patches'])) {
$this->io->write('Gathering patches for root package.');
$patches = $extra['patches'];
return $patches;
}
// If it's not specified there, look for a patches-file definition.
elseif (isset($extra['patches-file'])) {
$this->io->write('Gathering patches from patch file.');
$patches = file_get_contents($extra['patches-file']);
$patches = json_decode($patches, TRUE);
$error = json_last_error();
if ($error != 0) {
switch ($error) {
case JSON_ERROR_DEPTH:
$msg = ' - Maximum stack depth exceeded';
break;
case JSON_ERROR_STATE_MISMATCH:
$msg = ' - Underflow or the modes mismatch';
break;
case JSON_ERROR_CTRL_CHAR:
$msg = ' - Unexpected control character found';
break;
case JSON_ERROR_SYNTAX:
$msg = ' - Syntax error, malformed JSON';
break;
case JSON_ERROR_UTF8:
$msg = ' - Malformed UTF-8 characters, possibly incorrectly encoded';
break;
default:
$msg = ' - Unknown error';
break;
}
throw new \Exception('There was an error in the supplied patches file:' . $msg);
}
if (isset($patches['patches'])) {
$patches = $patches['patches'];
return $patches;
}
elseif(!$patches) {
throw new \Exception('There was an error in the supplied patch file');
}
}
else {
return array();
}
}
/**
* @param PackageEvent $event
* @throws \Exception
*/
public function postInstall(PackageEvent $event) {
// Get the package object for the current operation.
$operation = $event->getOperation();
/** @var PackageInterface $package */
$package = $this->getPackageFromOperation($operation);
$package_name = $package->getName();
if (!isset($this->patches[$package_name])) {
if ($this->io->isVerbose()) {
$this->io->write('No patches found for ' . $package_name . '.');
}
return;
}
$this->io->write(' - Applying patches for ' . $package_name . '');
// Get the install path from the package object.
$manager = $event->getComposer()->getInstallationManager();
$install_path = $manager->getInstaller($package->getType())->getInstallPath($package);
// Set up a downloader.
$downloader = new RemoteFilesystem($this->io, $this->composer->getConfig());
// Track applied patches in the package info in installed.json
$localRepository = $this->composer->getRepositoryManager()->getLocalRepository();
$localPackage = $localRepository->findPackage($package_name, $package->getVersion());
$extra = $localPackage->getExtra();
$extra['patches_applied'] = array();
foreach ($this->patches[$package_name] as $description => $url) {
$this->io->write(' ' . $url . ' (' . $description. ')');
try {
$this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::PRE_PATCH_APPLY, $package, $url, $description));
$this->getAndApplyPatch($downloader, $install_path, $url);
$this->eventDispatcher->dispatch(NULL, new PatchEvent(PatchEvents::POST_PATCH_APPLY, $package, $url, $description));
$extra['patches_applied'][$description] = $url;
}
catch (\Exception $e) {
$this->io->write(' Could not apply patch! Skipping. The error was: ' . $e->getMessage() . '');
$extra = $this->composer->getPackage()->getExtra();
if (getenv('COMPOSER_EXIT_ON_PATCH_FAILURE') || !empty($extra['composer-exit-on-patch-failure'])) {
throw new \Exception("Cannot apply patch $description ($url)!");
}
}
}
$localPackage->setExtra($extra);
$this->io->write('');
$this->writePatchReport($this->patches[$package_name], $install_path);
}
/**
* Get a Package object from an OperationInterface object.
*
* @param OperationInterface $operation
* @return PackageInterface
* @throws \Exception
*/
protected function getPackageFromOperation(OperationInterface $operation) {
if ($operation instanceof InstallOperation) {
$package = $operation->getPackage();
}
elseif ($operation instanceof UpdateOperation) {
$package = $operation->getTargetPackage();
}
else {
throw new \Exception('Unknown operation: ' . get_class($operation));
}
return $package;
}
/**
* Apply a patch on code in the specified directory.
*
* @param RemoteFilesystem $downloader
* @param $install_path
* @param $patch_url
* @throws \Exception
*/
protected function getAndApplyPatch(RemoteFilesystem $downloader, $install_path, $patch_url) {
// Local patch file.
if (file_exists($patch_url)) {
$filename = realpath($patch_url);
}
else {
// Generate random (but not cryptographically so) filename.
$filename = uniqid(sys_get_temp_dir().'/') . ".patch";
// Download file from remote filesystem to this location.
$hostname = parse_url($patch_url, PHP_URL_HOST);
$downloader->copy($hostname, $patch_url, $filename, FALSE);
}
// Modified from drush6:make.project.inc
$patched = FALSE;
// The order here is intentional. p1 is most likely to apply with git apply.
// p0 is next likely. p2 is extremely unlikely, but for some special cases,
// it might be useful.
$patch_levels = array('-p1', '-p0', '-p2');
foreach ($patch_levels as $patch_level) {
$checked = $this->executeCommand('cd %s && git --git-dir=. apply --check %s %s', $install_path, $patch_level, $filename);
if ($checked) {
// Apply the first successful style.
$patched = $this->executeCommand('cd %s && git --git-dir=. apply %s %s', $install_path, $patch_level, $filename);
break;
}
}
// In some rare cases, git will fail to apply a patch, fallback to using
// the 'patch' command.
if (!$patched) {
foreach ($patch_levels as $patch_level) {
// --no-backup-if-mismatch here is a hack that fixes some
// differences between how patch works on windows and unix.
if ($patched = $this->executeCommand("patch %s --no-backup-if-mismatch -d %s < %s", $patch_level, $install_path, $filename)) {
break;
}
}
}
// Clean up the temporary patch file.
if (isset($hostname)) {
unlink($filename);
}
// If the patch *still* isn't applied, then give up and throw an Exception.
// Otherwise, let the user know it worked.
if (!$patched) {
throw new \Exception("Cannot apply patch $patch_url");
}
}
/**
* Checks if the root package enables patching.
*
* @return bool
* Whether patching is enabled. Defaults to TRUE.
*/
protected function isPatchingEnabled() {
$extra = $this->composer->getPackage()->getExtra();
if (empty($extra['patches']) && empty($extra['patches-ignore']) && !isset($extra['patches-file'])) {
// The root package has no patches of its own, so only allow patching if
// it has specifically opted in.
return isset($extra['enable-patching']) ? $extra['enable-patching'] : FALSE;
}
else {
return TRUE;
}
}
/**
* Writes a patch report to the target directory.
*
* @param array $patches
* @param string $directory
*/
protected function writePatchReport($patches, $directory) {
$output = "This file was automatically generated by Composer Patches (https://github.com/cweagans/composer-patches)\n";
$output .= "Patches applied to this directory:\n\n";
foreach ($patches as $description => $url) {
$output .= $description . "\n";
$output .= 'Source: ' . $url . "\n\n\n";
}
file_put_contents($directory . "/PATCHES.txt", $output);
}
/**
* Executes a shell command with escaping.
*
* @param string $cmd
* @return bool
*/
protected function executeCommand($cmd) {
// Shell-escape all arguments except the command.
$args = func_get_args();
foreach ($args as $index => $arg) {
if ($index !== 0) {
$args[$index] = escapeshellarg($arg);
}
}
// And replace the arguments.
$command = call_user_func_array('sprintf', $args);
$output = '';
if ($this->io->isVerbose()) {
$this->io->write('' . $command . '');
$io = $this->io;
$output = function ($type, $data) use ($io) {
if ($type == Process::ERR) {
$io->write('' . $data . '');
}
else {
$io->write('' . $data . '');
}
};
}
return ($this->executor->execute($command, $output) == 0);
}
/**
* Recursively merge arrays without changing data types of values.
*
* Does not change the data types of the values in the arrays. Matching keys'
* values in the second array overwrite those in the first array, as is the
* case with array_merge.
*
* @param array $array1
* The first array.
* @param array $array2
* The second array.
* @return array
* The merged array.
*
* @see http://php.net/manual/en/function.array-merge-recursive.php#92195
*/
protected function arrayMergeRecursiveDistinct(array $array1, array $array2) {
$merged = $array1;
foreach ($array2 as $key => &$value) {
if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
$merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
}
else {
$merged[$key] = $value;
}
}
return $merged;
}
}