diff --git a/Makefile b/Makefile
index 6281293..b338ccf 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,8 @@ compile:
rm -rf '$(BUILD_DIR)/vendor'
composer update --dev --working-dir='$(BUILD_DIR)' --no-interaction
cd $(BUILD_DIR) && ./grumphp.phar run --testsuite=git_pre_commit && cd $(ROOT_DIR)
+ # Copy composer plugin
+ cp '$(BUILD_DIR)/src/Composer/GrumPHPPlugin.php' '$(ROOT_DIR)/src/Composer/GrumPHPPlugin.php'
# All good : lets finish up
cp '$(BUILD_DIR)/grumphp.phar' '$(ROOT_DIR)'
gpg --local-user toonverwerft@gmail.com --armor --detach-sign grumphp.phar
diff --git a/grumphp.phar b/grumphp.phar
index 69ae355..e7a631d 100755
Binary files a/grumphp.phar and b/grumphp.phar differ
diff --git a/grumphp.phar.asc b/grumphp.phar.asc
index 3319d54..06ef808 100644
--- a/grumphp.phar.asc
+++ b/grumphp.phar.asc
@@ -1,11 +1,11 @@
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1
-iQEcBAABAgAGBQJdt82RAAoJELtfAF1v/dieG/YIALSHTGsfBGUOinFH+RZz8ETE
-LgysSk84YyfJGEnDCBB8boZ1+QuWYaCnS992XXFtYURP1MBe8JPB5ryb9zZrcTMP
-Vr8DUzQBZzUHWRm88fMnpnwRtDFkNOsil6lgMvo5wNALNrOAM6DcxC3XYi+SAtPt
-bo0k5j3elUfAzn3FNWrL3UD7xtJEJp4cMiFmXJAmNyetsRAcDRMc3eNsPdigGVTo
-aeUjo6ca7wwwmH+D2q8GFPgID550/tZU+UAaPttSJq3SQov2Ildlu3zanPQNZeZt
-TFTwOvj5dn/9Uk8GoAl2WXzRlpcf7AEGXls6t5hD1mX4eho5GYIqLA3Vy6Ttsvg=
-=lvht
+iQEcBAABAgAGBQJd4RvmAAoJELtfAF1v/dieyFUH/jHnpCrajvGPqw+Uz8whbHTM
+9TVGtp2z0GO8dOeOpc9+YN7mXceCjC5zGiepBhYam2zElml/mjdpfaXbDdN3+h7t
+SWYGJSZaKafa2P3+XiB0rDt8Rk0R/W0eDNu0lAgqkoO5RHi//3yyTAovxjflnlgB
+Hr59Ft+hRWe+M7+7d5o/GyGrVrZaLaL4coAJl824nIqScK/kK+RNzxcGLHsjndx0
+lFs+uo8S8Om8iIWyDq50o/AE/IIjVSD1bz5L+No2Rq1S3JXkgp/9z+pax8m2SIcB
+3k/q7b5FPwLnc5Gpa/+I5xDnnsZ1+H52dnCvVhaniWKHRByvB1cRQ3xkIYW+XAE=
+=yuiX
-----END PGP SIGNATURE-----
diff --git a/src/Composer/GrumPHPPlugin.php b/src/Composer/GrumPHPPlugin.php
index e23d2f7..4212b65 100644
--- a/src/Composer/GrumPHPPlugin.php
+++ b/src/Composer/GrumPHPPlugin.php
@@ -5,15 +5,281 @@
namespace GrumPHP\Composer;
use Composer\Composer;
+use Composer\DependencyResolver\Operation\InstallOperation;
+use Composer\DependencyResolver\Operation\OperationInterface;
+use Composer\DependencyResolver\Operation\UninstallOperation;
+use Composer\DependencyResolver\Operation\UpdateOperation;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\Installer\PackageEvent;
+use Composer\Installer\PackageEvents;
use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
use Composer\Plugin\PluginInterface;
+use Composer\Script\Event;
+use Composer\Script\ScriptEvents;
-class GrumPHPPlugin implements PluginInterface
+class GrumPHPPlugin implements PluginInterface, EventSubscriberInterface
{
- public function activate(Composer $composer, IOInterface $io)
+ private const PACKAGE_NAME = 'phpro/grumphp';
+ private const APP_NAME = 'grumphp';
+ private const COMMAND_CONFIGURE = 'configure';
+ private const COMMAND_INIT = 'git:init';
+ private const COMMAND_DEINIT = 'git:deinit';
+
+ /**
+ * @var Composer
+ */
+ private $composer;
+
+ /**
+ * @var IOInterface
+ */
+ private $io;
+
+ /**
+ * @var bool
+ */
+ private $handledPackageEvent = false;
+
+ /**
+ * @var bool
+ */
+ private $configureScheduled = false;
+
+ /**
+ * @var bool
+ */
+ private $initScheduled = false;
+
+ /**
+ * @var bool
+ */
+ private $hasBeenRemoved = false;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function activate(Composer $composer, IOInterface $io): void
+ {
+ $this->composer = $composer;
+ $this->io = $io;
+ }
+
+ /**
+ * Attach package installation events:.
+ *
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ PackageEvents::PRE_PACKAGE_INSTALL => 'detectGrumphpAction',
+ PackageEvents::POST_PACKAGE_INSTALL => 'detectGrumphpAction',
+ PackageEvents::PRE_PACKAGE_UPDATE => 'detectGrumphpAction',
+ PackageEvents::PRE_PACKAGE_UNINSTALL => 'detectGrumphpAction',
+ ScriptEvents::POST_INSTALL_CMD => 'runScheduledTasks',
+ ScriptEvents::POST_UPDATE_CMD => 'runScheduledTasks',
+ ];
+ }
+
+ /**
+ * This method can be called by pre/post package events;
+ * We make sure to only run it once. This way Grumphp won't execute multiple times.
+ * The goal is to run it as fast as possible.
+ * For first install, this should also happen on POST install (because otherwise the plugin doesn't exist yet)
+ */
+ public function detectGrumphpAction(PackageEvent $event): void
+ {
+ if ($this->handledPackageEvent || !$this->guardPluginIsEnabled()) {
+ $this->handledPackageEvent = true;
+ return;
+ }
+
+ $this->handledPackageEvent = true;
+
+ $grumPhpOperations = $this->detectGrumphpOperations($event->getOperations());
+ if (!count($grumPhpOperations)) {
+ return;
+ }
+
+ // Check all GrumPHP operations to see if they are unanimously removing GrumPHP
+ // For example: an update might trigger an uninstall first - but we don't care about that.
+ $removalScheduled = array_reduce(
+ $grumPhpOperations,
+ function (?bool $theVote, OperationInterface $operation): bool {
+ $myVote = $operation instanceof UninstallOperation;
+
+ return null === $theVote ? $myVote : ($theVote && $myVote);
+ },
+ null
+ );
+
+ // Remove immediately once when we are positive about removal. (now that our dependencies are still there)
+ if ($removalScheduled) {
+ $this->runGrumPhpCommand(self::COMMAND_DEINIT);
+ $this->hasBeenRemoved = true;
+ return;
+ }
+
+ // Schedule install at the end of the process if we don't need to uninstall
+ $this->initScheduled = true;
+ $this->configureScheduled = true;
+ }
+
+ /**
+ * Runs the scheduled tasks after an update / install command.
+ */
+ public function runScheduledTasks(Event $event): void
+ {
+ if ($this->configureScheduled) {
+ $this->runGrumPhpCommand(self::COMMAND_CONFIGURE);
+ }
+
+ if ($this->initScheduled) {
+ $this->runGrumPhpCommand(self::COMMAND_INIT);
+ }
+ }
+
+ /**
+ * @param OperationInterface[] $operations
+ *
+ * @return OperationInterface[]
+ */
+ private function detectGrumphpOperations(array $operations): array
+ {
+ return array_values(array_filter(
+ $operations,
+ function (OperationInterface $operation): bool {
+ $package = $this->detectOperationPackage($operation);
+ return $this->guardIsGrumPhpPackage($package);
+ }
+ ));
+ }
+
+ private function detectOperationPackage(OperationInterface $operation): ?PackageInterface
+ {
+ switch (true) {
+ case $operation instanceof UpdateOperation:
+ return $operation->getTargetPackage();
+ case $operation instanceof InstallOperation:
+ case $operation instanceof UninstallOperation:
+ return $operation->getPackage();
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * This method also detects aliases / replaces statements which makes grumphp-shim possible.
+ */
+ private function guardIsGrumPhpPackage(?PackageInterface $package): bool
+ {
+ if (!$package) {
+ return false;
+ }
+
+ $normalizedNames = array_map('strtolower', $package->getNames());
+
+ return in_array(self::PACKAGE_NAME, $normalizedNames, true);
+ }
+
+ private function guardPluginIsEnabled(): bool
+ {
+ $extra = $this->composer->getPackage()->getExtra();
+
+ return !(bool) ($extra['grumphp']['disable-plugin'] ?? false);
+ }
+
+ private function runGrumPhpCommand(string $command): void
+ {
+ if (!$grumphp = $this->detectGrumphpExecutable()) {
+ $this->pluginErrored('no-exectuable');
+ return;
+ }
+
+ // Respect composer CLI settings
+ $ansi = $this->io->isDecorated() ? '--ansi' : '--no-ansi';
+ $silent = $command === self::COMMAND_CONFIGURE ? '--silent' : '';
+ $interaction = $this->io->isInteractive() ? '' : '--no-interaction';
+
+ // Windows requires double double quotes
+ // https://bugs.php.net/bug.php?id=49139
+ $windowsIsInsane = function (string $command) {
+ return $this->runsOnWindows() ? '"'.$command.'"' : $command;
+ };
+
+ // Run command
+ $process = @proc_open(
+ $run = $windowsIsInsane(implode(' ', array_map(
+ function (string $argument): string {
+ return escapeshellarg($argument);
+ },
+ array_filter([$grumphp, $command, $ansi, $silent, $interaction])
+ ))),
+ // Map process to current io
+ $descriptorspec = array(
+ 0 => array('file', 'php://stdin', 'r'),
+ 1 => array('file', 'php://stdout', 'w'),
+ 2 => array('file', 'php://stderr', 'w'),
+ ),
+ $pipes = []
+ );
+
+ // Check executable which is running:
+ if ($this->io->isVerbose()) {
+ $this->io->write('Running process : '.$run);
+ }
+
+ if (!is_resource($process)) {
+ $this->pluginErrored('no-process');
+ return;
+ }
+
+ // Loop on process until it exits normally.
+ do {
+ $status = proc_get_status($process);
+ } while ($status && $status['running']);
+
+ $exitCode = $status['exitcode'] ?? -1;
+ proc_close($process);
+
+ if ($exitCode !== 0) {
+ $this->pluginErrored('invalid-exit-code');
+ return;
+ }
+ }
+
+ private function detectGrumphpExecutable(): ?string
+ {
+ $config = $this->composer->getConfig();
+ $binDir = $this->ensurePlatformSpecificDirectorySeparator((string) $config->get('bin-dir'));
+ $suffixes = $this->runsOnWindows() ? ['.bat', ''] : ['.phar', ''];
+
+ return array_reduce(
+ $suffixes,
+ function (?string $carry, string $suffix) use ($binDir): ?string {
+ $possiblePath = $binDir.DIRECTORY_SEPARATOR.self::APP_NAME.$suffix;
+ if ($carry || !file_exists($possiblePath)) {
+ return $carry;
+ }
+
+ return $possiblePath;
+ }
+ );
+ }
+
+ private function runsOnWindows(): bool
+ {
+ return defined('PHP_WINDOWS_VERSION_BUILD');
+ }
+
+ private function ensurePlatformSpecificDirectorySeparator(string $path): string
+ {
+ return str_replace('/', DIRECTORY_SEPARATOR, $path);
+ }
+
+ private function pluginErrored(string $reason)
{
- $io->write('GrumPHP shim registered. you\'ll need to activate grumphp manually for now:');
- $io->write('Optionally: ./vendor/bin/grumphp configure');
- $io->write('Init hooks: ./vendor/bin/grumphp git:init');
+ $this->io->writeError('GrumPHP can not sniff your commits! ('.$reason.')');
}
}