diff --git a/bump b/bump new file mode 100644 index 000000000..e440e5c84 --- /dev/null +++ b/bump @@ -0,0 +1 @@ +3 \ No newline at end of file diff --git a/doc/installation/exotic.md b/doc/installation/exotic.md index bd8e74306..27598ddcd 100644 --- a/doc/installation/exotic.md +++ b/doc/installation/exotic.md @@ -18,7 +18,8 @@ You can manipulate how GrumPHP works based on settings in your composer file: "extra": { "grumphp": { "config-default-path": "path/to/grumphp.yml", - "project-path": "path/to/your/project/folder" + "project-path": "path/to/your/project/folder", + "disable-plugin": false } } } @@ -48,6 +49,14 @@ After changing a parameter, you might want to re-initialize your git hooks: php ./vendor/bin/grumphp git:init ``` +**disable-plugin** + +*Default: false* + +In some cases, you don't want composer to initialise GrumPHP automatically. +For example: on global installations activating the plugin is questionable. +Luckily you can opt-out on this behaviour. + ## Changing paths by using environment variables diff --git a/resources/config/console.yml b/resources/config/console.yml index f67abd82f..e2987fbe8 100644 --- a/resources/config/console.yml +++ b/resources/config/console.yml @@ -22,6 +22,7 @@ services: arguments: - '@config' - '@grumphp.util.filesystem' + - '@GrumPHP\Util\Paths' tags: - { name: 'console.command' } GrumPHP\Console\Command\RunCommand: diff --git a/src/Composer/DevelopmentIntegrator.php b/src/Composer/DevelopmentIntegrator.php index 7b91c4a47..a7db747c4 100644 --- a/src/Composer/DevelopmentIntegrator.php +++ b/src/Composer/DevelopmentIntegrator.php @@ -7,7 +7,7 @@ use Composer\Script\Event; use GrumPHP\Collection\ProcessArgumentsCollection; use GrumPHP\Process\ProcessFactory; -use Symfony\Component\Filesystem\Filesystem; +use GrumPHP\Util\Filesystem; class DevelopmentIntegrator { @@ -19,11 +19,11 @@ public static function integrate(Event $event): void $filesystem = new Filesystem(); $composerBinDir = $event->getComposer()->getConfig()->get('bin-dir'); - $executable = getcwd().'/bin/grumphp'; + $executable = dirname(__DIR__, 2).$filesystem->ensureValidSlashes('/bin/grumphp'); $composerExecutable = $composerBinDir.'/grumphp'; $filesystem->copy( - self::noramlizePath($executable), - self::noramlizePath($composerExecutable) + $filesystem->ensureValidSlashes($executable), + $filesystem->ensureValidSlashes($composerExecutable) ); $commandlineArgs = ProcessArgumentsCollection::forExecutable($composerExecutable); @@ -42,9 +42,4 @@ public static function integrate(Event $event): void $event->getIO()->write(''.$process->getOutput().''); } - - private static function noramlizePath(string $path): string - { - return str_replace('/', DIRECTORY_SEPARATOR, $path); - } } diff --git a/src/Composer/GrumPHPPlugin.php b/src/Composer/GrumPHPPlugin.php index 8bac678f2..4212b6575 100644 --- a/src/Composer/GrumPHPPlugin.php +++ b/src/Composer/GrumPHPPlugin.php @@ -6,47 +6,55 @@ 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\PackageEvents; 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; -use GrumPHP\Collection\ProcessArgumentsCollection; -use GrumPHP\Console\Command\ConfigureCommand; -use GrumPHP\Console\Command\Git\DeInitCommand; -use GrumPHP\Console\Command\Git\InitCommand; -use GrumPHP\Locator\ExternalCommand; -use GrumPHP\Process\ProcessFactory; -use Symfony\Component\Process\ExecutableFinder; class GrumPHPPlugin implements PluginInterface, EventSubscriberInterface { - const PACKAGE_NAME = 'phpro/grumphp'; + 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 */ - protected $composer; + private $composer; /** * @var IOInterface */ - protected $io; + private $io; + + /** + * @var bool + */ + private $handledPackageEvent = false; + + /** + * @var bool + */ + private $configureScheduled = false; /** * @var bool */ - protected $configureScheduled = false; + private $initScheduled = false; /** * @var bool */ - protected $initScheduled = false; + private $hasBeenRemoved = false; /** * {@inheritdoc} @@ -65,127 +73,213 @@ public function activate(Composer $composer, IOInterface $io): void public static function getSubscribedEvents(): array { return [ - PackageEvents::POST_PACKAGE_INSTALL => 'postPackageInstall', - PackageEvents::POST_PACKAGE_UPDATE => 'postPackageUpdate', - PackageEvents::PRE_PACKAGE_UNINSTALL => 'prePackageUninstall', + 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', ]; } /** - * When this package is updated, the git hook is also initialized. + * 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 postPackageInstall(PackageEvent $event): void + public function detectGrumphpAction(PackageEvent $event): void { - /** @var InstallOperation $operation */ - $operation = $event->getOperation(); - $package = $operation->getPackage(); + if ($this->handledPackageEvent || !$this->guardPluginIsEnabled()) { + $this->handledPackageEvent = true; + return; + } - if (!$this->guardIsGrumPhpPackage($package)) { + $this->handledPackageEvent = true; + + $grumPhpOperations = $this->detectGrumphpOperations($event->getOperations()); + if (!count($grumPhpOperations)) { return; } - // Schedule init when command is completed - $this->configureScheduled = true; - $this->initScheduled = true; - } + // 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; - /** - * When this package is updated, the git hook is also updated. - */ - public function postPackageUpdate(PackageEvent $event): void - { - /** @var UpdateOperation $operation */ - $operation = $event->getOperation(); - $package = $operation->getTargetPackage(); + return null === $theVote ? $myVote : ($theVote && $myVote); + }, + null + ); - if (!$this->guardIsGrumPhpPackage($package)) { + // 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 init when command is completed + // Schedule install at the end of the process if we don't need to uninstall $this->initScheduled = true; + $this->configureScheduled = true; } /** - * When this package is uninstalled, the generated git hooks need to be removed. + * Runs the scheduled tasks after an update / install command. */ - public function prePackageUninstall(PackageEvent $event): void + public function runScheduledTasks(Event $event): void { - /** @var UninstallOperation $operation */ - $operation = $event->getOperation(); - $package = $operation->getPackage(); - - if (!$this->guardIsGrumPhpPackage($package)) { - return; + if ($this->configureScheduled) { + $this->runGrumPhpCommand(self::COMMAND_CONFIGURE); } - // First remove the hook, before everything is deleted! - $this->deInitGitHook(); + if ($this->initScheduled) { + $this->runGrumPhpCommand(self::COMMAND_INIT); + } } - public function runScheduledTasks(Event $event): void + /** + * @param OperationInterface[] $operations + * + * @return OperationInterface[] + */ + private function detectGrumphpOperations(array $operations): array { - if ($this->initScheduled) { - $this->runGrumPhpCommand(ConfigureCommand::COMMAND_NAME); - } - if ($this->initScheduled) { - $this->initGitHook(); - } + return array_values(array_filter( + $operations, + function (OperationInterface $operation): bool { + $package = $this->detectOperationPackage($operation); + return $this->guardIsGrumPhpPackage($package); + } + )); } - protected function guardIsGrumPhpPackage(PackageInterface $package): bool + private function detectOperationPackage(OperationInterface $operation): ?PackageInterface { - return self::PACKAGE_NAME === $package->getName(); + switch (true) { + case $operation instanceof UpdateOperation: + return $operation->getTargetPackage(); + case $operation instanceof InstallOperation: + case $operation instanceof UninstallOperation: + return $operation->getPackage(); + default: + return null; + } } /** - * Initialize git hooks. + * This method also detects aliases / replaces statements which makes grumphp-shim possible. */ - protected function initGitHook(): void + private function guardIsGrumPhpPackage(?PackageInterface $package): bool { - $this->runGrumPhpCommand(InitCommand::COMMAND_NAME); + if (!$package) { + return false; + } + + $normalizedNames = array_map('strtolower', $package->getNames()); + + return in_array(self::PACKAGE_NAME, $normalizedNames, true); } - /** - * Deinitialize git hooks. - */ - protected function deInitGitHook(): void + private function guardPluginIsEnabled(): bool { - $this->runGrumPhpCommand(DeInitCommand::COMMAND_NAME); + $extra = $this->composer->getPackage()->getExtra(); + + return !(bool) ($extra['grumphp']['disable-plugin'] ?? false); } - /** - * Run the GrumPHP console to (de)init the git hooks. - */ - protected function runGrumPhpCommand(string $command): void + private function runGrumPhpCommand(string $command): void { - $config = $this->composer->getConfig(); - $commandLocator = new ExternalCommand($config->get('bin-dir'), new ExecutableFinder()); - $executable = $commandLocator->locate('grumphp'); + 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'; - $commandlineArgs = ProcessArgumentsCollection::forExecutable($executable); - $commandlineArgs->add($command); - $commandlineArgs->add('--no-interaction'); + // Windows requires double double quotes + // https://bugs.php.net/bug.php?id=49139 + $windowsIsInsane = function (string $command) { + return $this->runsOnWindows() ? '"'.$command.'"' : $command; + }; - $process = ProcessFactory::fromArguments($commandlineArgs); + // 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->isVeryVerbose()) { - $this->io->write('Running process : '.$process->getCommandLine()); + if ($this->io->isVerbose()) { + $this->io->write('Running process : '.$run); + } + + if (!is_resource($process)) { + $this->pluginErrored('no-process'); + return; } - $process->run(); - if (!$process->isSuccessful()) { - $this->io->write( - 'GrumPHP can not sniff your commits. Did you specify the correct git-dir?' - ); - $this->io->write(''.$process->getErrorOutput().''); + // 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'); + } - $this->io->write(''.$process->getOutput().''); + private function ensurePlatformSpecificDirectorySeparator(string $path): string + { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + private function pluginErrored(string $reason) + { + $this->io->writeError('GrumPHP can not sniff your commits! ('.$reason.')'); } } diff --git a/src/Configuration/ContainerBuilder.php b/src/Configuration/ContainerBuilder.php index 5f286f4b3..587451b0a 100644 --- a/src/Configuration/ContainerBuilder.php +++ b/src/Configuration/ContainerBuilder.php @@ -4,17 +4,18 @@ namespace GrumPHP\Configuration; +use GrumPHP\Util\Filesystem; use Symfony\Component\Config\FileLocator; use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; use Symfony\Component\DependencyInjection\ContainerBuilder as SymfonyContainerBuilder; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass; -use Symfony\Component\Filesystem\Filesystem; final class ContainerBuilder { public static function buildFromConfiguration(string $path): SymfonyContainerBuilder { + $filesystem = new Filesystem(); $container = new SymfonyContainerBuilder(); // Add compiler passes: @@ -28,7 +29,8 @@ public static function buildFromConfiguration(string $path): SymfonyContainerBui $container->addCompilerPass(new AddConsoleCommandPass()); // Load basic service file + custom user configuration - $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../resources/config')); + $configDir = dirname(__DIR__, 2).$filesystem->ensureValidSlashes('/resources/config'); + $loader = new YamlFileLoader($container, new FileLocator($configDir)); $loader->load('console.yml'); $loader->load('formatter.yml'); $loader->load('linters.yml'); @@ -41,7 +43,6 @@ public static function buildFromConfiguration(string $path): SymfonyContainerBui $loader->load('util.yml'); // Load grumphp.yml file: - $filesystem = new Filesystem(); if ($filesystem->exists($path)) { $loader->load($path); } diff --git a/src/Console/Command/ConfigureCommand.php b/src/Console/Command/ConfigureCommand.php index 6728bd741..5871532b5 100644 --- a/src/Console/Command/ConfigureCommand.php +++ b/src/Console/Command/ConfigureCommand.php @@ -7,6 +7,7 @@ use Exception; use GrumPHP\Configuration\GrumPHP; use GrumPHP\Util\Filesystem; +use GrumPHP\Util\Paths; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; @@ -29,18 +30,23 @@ class ConfigureCommand extends Command * @var Filesystem */ protected $filesystem; + /** + * @var Paths + */ + private $paths; /** * @var InputInterface */ protected $input; - public function __construct(GrumPHP $config, Filesystem $filesystem) + public function __construct(GrumPHP $config, Filesystem $filesystem, Paths $paths) { parent::__construct(); $this->config = $config; $this->filesystem = $filesystem; + $this->paths = $paths; } public static function getDefaultName(): string @@ -59,6 +65,12 @@ protected function configure(): void InputOption::VALUE_NONE, 'Forces overwriting the configuration file when it already exists.' ); + $this->addOption( + 'silent', + null, + InputOption::VALUE_NONE, + 'Only output what really matters.' + ); } /** @@ -67,11 +79,10 @@ protected function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { $this->input = $input; - - $grumphpConfigName = $this->input->getOption('config'); + $configFile = $this->paths->getConfigFile(); $force = $input->getOption('force'); - if ($this->filesystem->exists($grumphpConfigName) && !$force) { - if ($input->isInteractive()) { + if ($this->filesystem->exists($configFile) && !$force) { + if (!$input->getOption('silent')) { $output->writeln('GrumPHP is already configured!'); } @@ -87,14 +98,14 @@ public function execute(InputInterface $input, OutputInterface $output): int } // Check write action - $written = $this->writeConfiguration($configuration); + $written = $this->writeConfiguration($configFile, $configuration); if (!$written) { $output->writeln('The configuration file could not be saved. Give me some permissions!'); return 1; } - if ($input->isInteractive()) { + if (!$input->getOption('silent')) { $output->writeln('GrumPHP is configured and ready to kick ass!'); } @@ -113,6 +124,7 @@ protected function buildConfiguration(InputInterface $input, OutputInterface $ou 'Do you want to create a grumphp.yml file?', 'Yes' ); + $question = new ConfirmationQuestion($questionString, true); if (!$helper->ask($input, $output, $question)) { return []; @@ -122,7 +134,7 @@ protected function buildConfiguration(InputInterface $input, OutputInterface $ou $tasks = []; if ($input->isInteractive()) { $question = new ChoiceQuestion( - 'Which tasks do you want to run?', + $this->createQuestionString('Which tasks do you want to run?', null, ''), $this->config->getRegisteredTasks() ); $question->setMultiselect(true); @@ -142,16 +154,15 @@ protected function buildConfiguration(InputInterface $input, OutputInterface $ou protected function createQuestionString(string $question, string $default = null, string $separator = ':'): string { return null !== $default ? - sprintf('%s [%s]%s ', $question, $default, $separator) : - sprintf('%s%s ', $question, $separator); + sprintf('%s [%s]%s ', $question, $default, $separator) : + sprintf('%s%s ', $question, $separator); } - protected function writeConfiguration(array $configuration): bool + protected function writeConfiguration(string $configFile, array $configuration): bool { try { $yaml = Yaml::dump($configuration); - $grumphpConfigName = $this->input->getOption('config'); - $this->filesystem->dumpFile($grumphpConfigName, $yaml); + $this->filesystem->dumpFile($configFile, $yaml); return true; } catch (Exception $e) { diff --git a/src/Util/Paths.php b/src/Util/Paths.php index 844ce9c76..badfe8445 100644 --- a/src/Util/Paths.php +++ b/src/Util/Paths.php @@ -74,6 +74,11 @@ public function getBinDir(): string return $this->guessedPaths->getBinDir(); } + public function getConfigFile(): string + { + return $this->guessedPaths->getConfigFile(); + } + public function getProjectDirRelativeToGitDir(): string { return $this->filesystem->makePathRelative( diff --git a/test/E2E/AbstractE2ETestCase.php b/test/E2E/AbstractE2ETestCase.php index 167ec8668..f289a0d3a 100644 --- a/test/E2E/AbstractE2ETestCase.php +++ b/test/E2E/AbstractE2ETestCase.php @@ -290,6 +290,7 @@ protected function installComposer(string $path, array $arguments = []) 'install', '--optimize-autoloader', '--no-interaction', + '-vvv' ], $arguments), $path