From 977666796d224f6e96e031fe93839785463b166c Mon Sep 17 00:00:00 2001 From: Morten Rugaard Date: Tue, 25 May 2021 23:09:07 +0200 Subject: [PATCH] =?UTF-8?q?Init=20commit=20=F0=9F=A5=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 15 + .github/workflows/tests.yml | 31 ++ .gitignore | 7 + LICENSE | 21 ++ README.md | 79 +++++ composer.json | 58 ++++ src/Exception/NoPathsProvidedException.php | 16 + src/Hooks/PreCommit/PhpCodeStyleCommand.php | 291 ++++++++++++++++++ src/Hooks/PreCommit/PhpLintCommand.php | 132 ++++++++ .../PreCommit/PhpStaticAnalysisCommand.php | 232 ++++++++++++++ src/Hooks/PrePush/PhpTestSuiteCommand.php | 123 ++++++++ 11 files changed, 1005 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Exception/NoPathsProvidedException.php create mode 100644 src/Hooks/PreCommit/PhpCodeStyleCommand.php create mode 100644 src/Hooks/PreCommit/PhpLintCommand.php create mode 100644 src/Hooks/PreCommit/PhpStaticAnalysisCommand.php create mode 100644 src/Hooks/PrePush/PhpTestSuiteCommand.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..42322b1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.php] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2873357 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: "tests" +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: + - php8.0 + - php8.1 + steps: + - uses: actions/checkout@v1 + - name: "Cache dependencies installed with composer" + uses: actions/cache@v1 + with: + path: ~/.composer/cache + key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + php${{ matrix.php-version }}-composer- + - name: "Validate composer.json file" + run: composer validate + - name: "Install PHP coding style checker" + run: composer require "squizlabs/php_codesniffer":"^3.0" + - name: "Check coding style after PSR-12 standard" + run: php ./vendor/bin/phpcs --standard=PSR12 --encoding=utf-8 -n ./src diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a890e58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +/.idea +index.php +composer.phar +composer.lock +.DS_Store +.phpunit.result.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a296aa0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Morten Rugaard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..652da2b --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# 🪝 Git Hooks for PHP [![GitHub Actions (tests)](https://github.com/rugaard/git-hooks-php/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/rugaard/git-hooks-php/actions/workflows/tests.yml) + +This is a "plugin" package which seamlessly integrates with the [Git Hooks](https://github.com/rugaard/git-hooks) package. + +It will install `git` hooks, that will automatically run multiple checks on your projects PHP files, to make sure they do not contain errors and follow the expected coding standards. + +## 📦 Installation + +You install the package via [Composer](https://getcomposer.org/) by using the following command: + +```shell +composer require rugaard/git-hooks rugaard/git-hooks-php +``` + +## 📝 Configuration + +To change the default configuration of one or more script, you need to have a `git-hooks.config.json` file in your project root. If you don't, you can create it with the following command: + +```shell +./vendor/bin/git-hooks config +``` + +### `Rugaard\GitHooks\PHP\Hooks\PreCommit\PhpCodeStyleCommand` + +Checks all staged `.php` files for coding style errors. + +| Parameter | Description | Default | +| :--- | :--- | :---: | +| `encoding` | Encoding of the files being checked | `utf-8` | +| `hideWarnings` | Hide code style warnings | `true` | +| `onlyStaged` | Only check code style on staged PHP files. | `true` | +| `paths` | Paths to directories/files that should be checked. _Only used when `onlyStaged` is set to `false`_. | `[]` | +| `config` | Path to custom configuration file.| `null` | + +**Note:** By default, if a valid `config` has not been provided, this command will look for `phpcs.xml` or `phpcs.xml.dist` as an alternative. + +If it finds any of those options, the above paramters will be ignored, and the configuration file will take priority. + +### `Rugaard\GitHooks\PHP\Hooks\PreCommit\PhpLintCommand` + +Checks all staged `.php` files for syntax errors. + +_**Script has nothing to configure**_ + +### `Rugaard\GitHooks\PHP\Hooks\PreCommit\PhpStaticAnalysisCommand` + +Statically analyzes all (or staged) `.php` files for errors. + +| Parameter | Description | Default | +| :--- | :--- | :---: | +| `level` | Level of strictness to use. From `0` to `9`. | `8` | +| `onlyStaged` | Only analyze staged PHP files. | `true` | +| `paths` | Only the following directories/files should be checked. | `[]` | +| `memory-limit` | Set memory limit of process. Fx. `512M` for 512 MB. | `null` | +| `config` | Path to custom configuration file. | `null` | + +**Note:** By default, if a valid `config` has not been provided, this command will look for `phpstan.neon` or `phpstan.neon.dist` as an alternative. + +If it finds any of those options, the above paramters will be ignored, and the configuration file will take priority. + +### `Rugaard\GitHooks\PHP\Hooks\PrePush\PhpTestSuiteCommand` + +Runs the projects test suite(s). + +| Parameter | Description | Default | +| :--- | :--- | :---: | +| `driver` | Application to run test suite(s). Supports: `phpunit` or `pest`* | `phpunit` | +| `printer` | Change printer used by `driver` application | `\\NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer` | +| `config` | Path to custom configuration file | `null` | + +_* Requires `pest` to be installed in your project_ + +**Note:** By default, if a valid `config` has not been provided, this command will look for `phpunit.xml` or `phpunit.xml.dist` as an alternative. + +If it finds any of those options, the above paramters will be ignored, and the configuration file will take priority. + +## 🚓 License + +This package is licensed under [MIT](https://github.com/rugaard/git-hooks-php/blob/main/LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6501993 --- /dev/null +++ b/composer.json @@ -0,0 +1,58 @@ +{ + "name": "rugaard/git-hooks-php", + "type": "git-hook", + "description": "PHP related Git hooks.", + "keywords": [ + "morten", + "rugaard", + "morten rugaard", + "composer", + "plugin", + "composer plugin", + "git", + "hooks", + "git hooks", + "php", + "php git hooks" + ], + "authors": [ + { + "name": "Morten Rugaard", + "email": "morten@rugaard.me", + "homepage": "https://github.com/rugaard", + "role": "Developer" + } + ], + "license": "MIT", + "homepage": "https://github.com/rugaard", + "support": { + "issues": "https://github.com/rugaard/git-hooks-php/issues", + "source": "https://github.com/rugaard/git-hooks-php" + }, + "require": { + "php": "^8.0", + "ext-json": "*", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^9.0", + "rugaard/git-hooks": "^1.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "require-dev": { + "roave/security-advisories": "dev-latest" + }, + "autoload": { + "psr-4": { + "Rugaard\\GitHooks\\PHP\\": "src" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "rugaard/git-hooks": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Exception/NoPathsProvidedException.php b/src/Exception/NoPathsProvidedException.php new file mode 100644 index 0000000..f5db3a9 --- /dev/null +++ b/src/Exception/NoPathsProvidedException.php @@ -0,0 +1,16 @@ +setName('php:cs') + ->setDescription('Checks coding style in staged PHP files'); + } + + /** + * Executes the current command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + // Instantiate Git Hooks Styles. + $io = new GitHookStyle($input, $output); + + // Generate section. + $io->header('Checking coding style in PHP files', false); + + // Make sure PHP Code Style checker exists. + $driverPath = getcwd() . '/vendor/bin/phpcs'; + if (!file_exists($driverPath)) { + $io->newLine(); + $io->block('Could not locate PHP-CS: ' . $driverPath, 'ERROR', 'fg=red;options=bold', ''); + return Command::FAILURE; + } + + // Determine which standard, we should use for style checking. + if ($this->getConfig('config') !== null && (strpos($this->getConfig('config'), '.xml') !== false && file_exists(getcwd() . '/' . $this->getConfig('config')))) { + $config = getcwd() . '/' . $this->getConfig('config'); + $io->writeln('[INFO] Using custom configuration file (' . $this->getConfig('config') . ')' . "\n"); + } elseif (file_exists(getcwd() . '/phpcs.xml')) { + $config = getcwd() . '/phpcs.xml'; + $io->writeln('[INFO] Using project configuration file (phpcs.xml)' . "\n"); + } elseif (file_exists(getcwd() . '/phpcs.xml.dist')) { + $config = getcwd() . '/phpcs.xml.dist'; + $io->writeln('[INFO] Using distributed configuration file (phpcs.xml.dist)' . "\n"); + } + + if (!empty($config)) { + // Prepare process. + $process = new Process(array_merge([ + $driverPath, + '--report=json', + '--standard=' . $config, + ])); + } else { + // By default we will only check staged PHP files. + // But if this is disabled, we will look to the "paths" array. + // If this is also empty, we'll default to current directory. + if ($this->getConfig('onlyStaged')) { + // Get staged PHP files. + $pathsToCheck = $this->getGitStagedFilesByExtension('php', [ + '--diff-filter=d' + ]); + + // If we don't have any staged PHP files, + // then we'll mark the command as successful. + if (empty($pathsToCheck)) { + $io->block('Done', 'OK', 'fg=green;options=bold', ''); + return Command::SUCCESS; + } + + // Get unstaged PHP files. + $unstagedFiles = $this->getGitUnstagedFilesByExtension('php', [ + '--diff-filter=d' + ]); + + // Check if we have a case, + // where staged files has unstaged changes. + $stagedFilesWithUnstagedChanges = array_intersect($pathsToCheck, $unstagedFiles); + if (count($stagedFilesWithUnstagedChanges) > 0) { + $io->newLine(); + $io->block('Following staged files has unstaged changes:', 'ERROR', 'fg=red;options=bold', ''); + $io->listing($stagedFilesWithUnstagedChanges); + $io->writeln('Fix the error and try again.'); + return Command::FAILURE; + } + } else { + try { + // Get paths provided by config. + $pathsToCheck = $this->getPaths(); + } catch (NoPathsProvidedException $e) { + $io->block('No paths were provided.', 'ERROR', 'fg=red;options=bold', ''); + $io->writeln('Add paths in your "git-hooks.config.json" file.'); + return Command::FAILURE; + } + } + + // Validate provided standard. + // If it's not supported, use PSR-12 as fallback. + $standard = array_search($this->getConfig('standard'), $this->supportedStandards()); + if ($standard === false) { + $standard = 'PSR12'; + } + + // Output info message. + $io->writeln('[INFO] Using "' . $this->supportedStandards()[$standard] . '" standard' . "\n"); + + // Prepare process. + $process = new Process(array_merge([ + $driverPath, + '--report=json', + '--standard=' . $standard, + '--encoding=' . (!empty($this->getConfig('encoding')) ? $this->getConfig('encoding') : 'utf-8'), + $this->getConfig('hideWarnings') ? '-n' : '', + ], $pathsToCheck)); + } + + // Execute process. + $process->run(); + + // If code style check returned without any errors + // we'll output a successful message. + if ($process->isSuccessful()) { + $io->block('Done', 'OK', 'fg=green;options=bold', ''); + return Command::SUCCESS; + } + + // Parse failed data. + $failedResponse = json_decode($process->getOutput(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + $io->block('Errors found', 'ERROR', 'fg=red;options=bold', ''); + $io->writeln('Could not decode errors returned by code style checker.'); + return Command::FAILURE; + } + + // Array of failed files. + $fixableFiles = []; + + // Build overview of error messages. + foreach ($failedResponse['files'] as $filename => $failure) { + // If file has zero messages, + // we'll skip it and move on. + if (empty($failure['messages'])) { + continue; + } + + // Generate relative filename. + $relativeFilename = str_replace(getcwd() . DIRECTORY_SEPARATOR, '', $filename); + + // Array of table rows. + $rows = []; + + // Loop through errors for current file. + // and add them to table overview. + foreach ($failure['messages'] as $data) { + // Separate previous row. + if (count($rows) > 0) { + $rows[] = new TableSeparator(); + } + + // If message is fixable, add the filename + // to the array of fixable files. + if (!in_array($relativeFilename, $fixableFiles) && $data['fixable']) { + $fixableFiles[] = $relativeFilename; + } + + // Add error to row. + $rows[] = [ + '' . $data['type'] . '', + $data['line'] . ':' . $data['column'], + '[' . ($data['fixable'] ? 'x' : ' ') . ']', + $data['message'] + ]; + } + + // Render error table for current file. + $io->writeln('' . $relativeFilename . ''); + $io->boxedTable([ + 'Type', + 'Line', + 'Fix', + 'Message', + ], $rows); + } + + $io->block('Coding style errors found', 'ERROR', 'fg=red;options=bold', ''); + + // If PHP-CBF is available, then generate helping message. + if (!empty($fixableFiles) && file_exists(getcwd() . '/vendor/bin/phpcbf')) { + $io->writeln('Tip: Some errors can be fixed automatically by using following command:' . "\n"); + if (!empty($config)) { + $io->writeln(' ./vendor/bin/phpcbf --standard=' . $config . ' ' . implode(' ', $fixableFiles) . '' . "\n"); + } else { + $io->writeln(sprintf( + ' ./vendor/bin/phpcbf %s %s %s%s' . "\n", + '--standard=' . $standard, + '--encoding=' . (!empty($this->getConfig('encoding')) ? $this->getConfig('encoding') : 'utf-8'), + $this->getConfig('hideWarnings') ? '-n ' : ' ', + implode(' ', $fixableFiles) + )); + } + } + + $io->writeln('Fix the error(s) and try again.' . "\n"); + return Command::FAILURE; + } + + /** + * Get all paths to check. + * + * @return array + * @throws \Rugaard\GitHooks\PHP\Exception\NoPathsProvidedException + */ + protected function getPaths(): array + { + $paths = array_filter($this->getConfig('paths') ?? [], function ($path) { + return (is_dir(getcwd() . DIRECTORY_SEPARATOR . $path) || file_exists(getcwd() . DIRECTORY_SEPARATOR . $path)); + }); + + if (empty($paths)) { + throw new NoPathsProvidedException('pre-commit', 'An array of paths was not provided.', 412); + } + + return $paths; + } + + /** + * Get array of supported coding standards. + * + * @return array + */ + protected function supportedStandards(): array + { + return [ + 'PSR1' => 'PSR-1', + 'PSR2' => 'PSR-2', + 'PSR12' => 'PSR-12', + 'Generic' => 'Generic', + 'MySource' => 'MySource', + 'Squiz' => 'Squiz', + 'Zend' => 'Zend' + ]; + } + + /** + * Get command's default configuration. + * + * @return array + */ + protected function getDefaultConfig(): array + { + return [ + 'config' => null, + 'encoding' => 'utf-8', + 'hideWarnings' => true, + 'onlyStaged' => true, + 'paths' => [] + ]; + } + + /** + * Type of git-hook command belongs to. + * + * @return string + */ + public static function hookType(): string + { + return 'pre-commit'; + } +} diff --git a/src/Hooks/PreCommit/PhpLintCommand.php b/src/Hooks/PreCommit/PhpLintCommand.php new file mode 100644 index 0000000..604184f --- /dev/null +++ b/src/Hooks/PreCommit/PhpLintCommand.php @@ -0,0 +1,132 @@ +setName('php:lint') + ->setDescription('Checks staged PHP files for syntax errors'); + } + + /** + * Executes the current command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + // Instantiate Git Hooks Styles. + $io = new GitHookStyle($input, $output); + + // Generate section. + $io->header('Checking PHP files for syntax errors'); + + // Get staged PHP files. + $stagedFiles = $this->getGitStagedFilesByExtension('php', [ + '--diff-filter=d' + ]); + + // If we don't have any staged PHP files, + // then we'll mark the command as successful. + if (empty($stagedFiles)) { + $io->block('Done', 'OK', 'fg=green;options=bold', ''); + return Command::SUCCESS; + } + + // Get unstaged PHP files. + $unstagedFiles = $this->getGitUnstagedFilesByExtension('php', [ + '--diff-filter=d' + ]); + + // Check if we have a case, + // where staged files has unstaged changes. + $stagedFilesWithUnstagedChanges = array_intersect($stagedFiles, $unstagedFiles); + if (count($stagedFilesWithUnstagedChanges) > 0) { + $io->block('Following staged files has unstaged changes:', 'ERROR', 'fg=red;options=bold', ''); + $io->listing($stagedFilesWithUnstagedChanges); + $io->writeln('Fix the error and try again.'); + return Command::FAILURE; + } + + // Array of files that failed linting. + $failedFiles = []; + + foreach ($stagedFiles as $file) { + // Check file for syntax errors. + $response = trim(shell_exec(PHP_BINARY . ' -l ' . $file), "\n"); + + // If no errors were found, + // we'll silently move on to next file. + if ($response === 'No syntax errors detected in ' . $file) { + continue; + } + + // Add failed file to internal array + // with error message attached. + $failedFiles[$file] = explode("\n", $response)[0]; + } + + // If one or more files failed linting, + // we'll abort the script while listing + // the failed files and the error messages. + if (count($failedFiles) > 0) { + $io->block('Syntax errors were found.', 'ERROR', 'fg=red;options=bold', ''); + foreach ($failedFiles as $file => $errorMessage) { + $io->writeln([ + sprintf('%s', OutputFormatter::escapeTrailingBackslash($file)), + $errorMessage . "\n" + ]); + } + $io->writeln('Fix the error(s) and try again.'); + return Command::FAILURE; + } + + // Output successful message. + $io->block('Done', 'OK', 'fg=green;options=bold', ''); + + return Command::SUCCESS; + } + + /** + * Get command's default configuration. + * + * @return array + */ + protected function getDefaultConfig(): array + { + return []; + } + + /** + * Type of git-hook command belongs to. + * + * @return string + */ + public static function hookType(): string + { + return 'pre-commit'; + } +} diff --git a/src/Hooks/PreCommit/PhpStaticAnalysisCommand.php b/src/Hooks/PreCommit/PhpStaticAnalysisCommand.php new file mode 100644 index 0000000..ab5cfd5 --- /dev/null +++ b/src/Hooks/PreCommit/PhpStaticAnalysisCommand.php @@ -0,0 +1,232 @@ +setName('php:analyze') + ->setDescription('Static analyze staged PHP files'); + } + + /** + * Executes the current command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + // Instantiate Git Hooks Styles. + $io = new GitHookStyle($input, $output); + + // Generate section. + $io->header('Static analysis of PHP files', false); + + // Make sure PHP Code Style checker exists. + $driverPath = getcwd() . '/vendor/bin/phpstan'; + if (!file_exists($driverPath)) { + $io->newLine(); + $io->block('Could not locate PHPStan: ' . $driverPath, 'ERROR', 'fg=red;options=bold', ''); + return Command::FAILURE; + } + + $pathsToCheck = array_filter($this->getConfig('paths') ?? [], static function ($path) { + return (is_dir(getcwd() . DIRECTORY_SEPARATOR . $path) || file_exists(getcwd() . DIRECTORY_SEPARATOR . $path)); + }); + + // If only we're should check staged files, + // then we'll need to update "pathsToCheck" + // with the list of staged files. + if ($this->getConfig('onlyStaged')) { + // Get staged PHP files. + $pathsToCheck = array_filter($this->getGitStagedFilesByExtension('php', [ + '--diff-filter=d' + ]), static function ($stagedFile) use ($pathsToCheck) { + foreach ($pathsToCheck as $pathToCheck) { + if (str_starts_with($stagedFile, $pathToCheck)) { + return true; + } + } + return false; + }); + + // If we don't have any staged PHP files, + // then we'll mark the command as successful. + if (empty($pathsToCheck)) { + $io->block('Done', 'OK', 'fg=green;options=bold', ''); + return Command::SUCCESS; + } + + // Get unstaged PHP files. + $unstagedFiles = $this->getGitUnstagedFilesByExtension('php', [ + '--diff-filter=d' + ]); + + // Check if we have a case, + // where staged files has unstaged changes. + $stagedFilesWithUnstagedChanges = array_intersect($pathsToCheck, $unstagedFiles); + if (count($stagedFilesWithUnstagedChanges) > 0) { + $io->block('Following staged files has unstaged changes:', 'ERROR', 'fg=red;options=bold', ''); + $io->listing($stagedFilesWithUnstagedChanges); + $io->writeln('Fix the error and try again.'); + return Command::FAILURE; + } + } + + // Determine which config, we should use for analysis. + if ($this->getConfig('config') !== null && file_exists(getcwd() . '/' . $this->getConfig('config'))) { + $config = getcwd() . '/' . $this->getConfig('config'); + $io->writeln('[INFO] Using custom configuration file (' . $this->getConfig('config') . ')' . "\n"); + } elseif (file_exists(getcwd() . '/phpstan.neon')) { + $config = getcwd() . '/phpstan.neon'; + $io->writeln('[INFO] Using project configuration file (phpstan.neon)' . "\n"); + } elseif (file_exists(getcwd() . '/phpstan.neon.dist')) { + $config = getcwd() . '/phpstan.neon.dist'; + $io->writeln('[INFO] Using distributed configuration file (phpstan.neon.dist)' . "\n"); + } + + if (!empty($config)) { + // Prepare process. + $process = new Process(array_merge( + [ + $driverPath, + 'analyze', + '--error-format=json', + '--configuration=' . $config, + '--no-progress', + '--no-ansi', + ], + !empty($this->getConfig('memory-limit')) ? ['--memory-limit=' . $this->getConfig('memory-limit')] : [], + $pathsToCheck + )); + } else { + // If no paths has been provided, + // abort check with an error. + if (empty($pathsToCheck)) { + $io->block('No paths were provided.', 'ERROR', 'fg=red;options=bold', ''); + $io->writeln('Add paths in your "git-hooks.config.json" file.'); + return Command::FAILURE; + } + + // Make sure we have a valid and supported level. + $level = $this->getConfig('level') !== null && ($this->getConfig('level') >= 0 && $this->getConfig('level') <= 8) ? $this->getConfig('level') : 1; + + // Output info message + $io->writeln('[INFO] Using default configuration with rule level ' . $level . '' . "\n"); + + // Prepare process. + $process = new Process(array_merge( + [ + $driverPath, + 'analyze', + '--error-format=json', + '--level=' . $level, + '--no-progress', + '--no-ansi', + ], + !empty($this->getConfig('memory-limit')) ? ['--memory-limit=' . $this->getConfig('memory-limit')] : [], + $pathsToCheck + )); + } + + // Execute process. + $process->run(); + + // If analysis returned without any errors + // we'll output a successful message. + if ($process->isSuccessful()) { + $io->block('Done', 'OK', 'fg=green;options=bold', ''); + return Command::SUCCESS; + } + + // Parse error data. + $errors = json_decode($process->getOutput(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + $io->block('Errors found', 'ERROR', 'fg=red;options=bold', ''); + $io->writeln('Could not decode errors returned by static analysis tool.'); + return Command::FAILURE; + } + + // Build overview of error messages. + foreach ($errors['files'] as $filename => $errorData) { + // Array of table rows. + $rows = []; + + // Loop through errors for current file. + // and add them to table overview. + foreach ($errorData['messages'] as $error) { + // Separate previous row. + if (count($rows) > 0) { + $rows[] = new TableSeparator(); + } + + // Add error to row. + $rows[] = [ + $error['line'], + $error['message'] + ]; + } + + // Render error table for current file. + $io->boxedTable([ + 'Line', + str_replace(getcwd() . DIRECTORY_SEPARATOR, '', $filename) + ], $rows); + } + + // Output error message. + $io->block('Found ' . $errors['totals']['file_errors'] . ' errors', 'ERROR', 'fg=red;options=bold', ''); + $io->writeln('Fix the error(s) and try again.' . "\n"); + + return Command::FAILURE; + } + + /** + * Get command's default configuration. + * + * @return array + */ + protected function getDefaultConfig(): array + { + return [ + 'config' => null, + 'memory-limit' => null, + 'level' => 8, + 'onlyStaged' => true, + 'paths' => [], + ]; + } + + /** + * Type of git-hook command belongs to. + * + * @return string + */ + public static function hookType(): string + { + return 'pre-commit'; + } +} diff --git a/src/Hooks/PrePush/PhpTestSuiteCommand.php b/src/Hooks/PrePush/PhpTestSuiteCommand.php new file mode 100644 index 0000000..05353b0 --- /dev/null +++ b/src/Hooks/PrePush/PhpTestSuiteCommand.php @@ -0,0 +1,123 @@ +setName('php:test-suite') + ->setDescription('Run PHP test suite') + ->addArgument('remote', InputArgument::REQUIRED, 'Name of remote') + ->addArgument('url', InputArgument::REQUIRED, 'URL of remote'); + } + + /** + * Executes the current command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + // Instantiate Git Hooks Styles. + $io = new GitHookStyle($input, $output); + + // Generate section. + $io->header('Running PHP test suite', false); + + // Make sure driver exists. + $driverPath = getcwd() . '/vendor/bin/' . (in_array($this->getConfig('driver'), ['phpunit', 'pest']) ? $this->getConfig('driver') : 'phpunit'); + if (!file_exists($driverPath)) { + $io->newLine(); + $io->block('Could not locate driver: ' . $driverPath, 'ERROR', 'fg=red;options=bold', ''); + return Command::FAILURE; + } + + // Determine which standard, we should use for style checking. + if ($this->getConfig('config') !== null && file_exists(getcwd() . '/' . $this->getConfig('config'))) { + $config = getcwd() . '/' . $this->getConfig('config'); + $io->writeln('[INFO] Using custom configuration file (' . $this->getConfig('config') . ')' . "\n"); + } elseif (file_exists(getcwd() . '/phpunit.xml')) { + $config = getcwd() . '/phpunit.xml'; + $io->writeln('[INFO] Using project configuration file (phpunit.xml)' . "\n"); + } elseif (file_exists(getcwd() . '/phpunit.xml.dist')) { + $config = getcwd() . '/phpunit.xml.dist'; + $io->writeln('[INFO] Using distributed configuration file (phpunit.xml.dist)' . "\n"); + } else { + $io->block('No configuration file found.', 'ERROR', 'fg=red;options=bold', ''); + return Command::FAILURE; + } + + // Prepare process. + $process = new Process(array_merge( + [ + $driverPath, + '--configuration=' . $config, + ], + !empty($this->getConfig('printer')) && class_exists($this->getConfig('printer')) ? ['--printer=' . $this->getConfig('printer')] : [], + )); + + // Execute process. + $process->run(function ($type, $buffer) { + print $buffer; + }); + + // Validate result of process + // and output appropriate message. + $io->writeln(''); + if (!$process->isSuccessful()) { + $io->block('Test suite failed.', 'ERROR', 'fg=red;options=bold', ''); + $io->writeln('Fix the error(s) and try again.'); + return Command::FAILURE; + } else { + $io->block('Done', 'OK', 'fg=green;options=bold', ''); + return Command::SUCCESS; + } + } + + /** + * Get command's default configuration. + * + * @return array + */ + protected function getDefaultConfig(): array + { + return [ + 'driver' => 'phpunit', + 'config' => null, + 'printer' => '\\NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer', + ]; + } + + /** + * Type of git-hook command belongs to. + * + * @return string + */ + public static function hookType(): string + { + return 'pre-push'; + } +}